35 changed files with 10334 additions and 0 deletions
@ -0,0 +1,13 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8" /> |
||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> |
||||
|
<title>健康商城</title> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div id="app"></div> |
||||
|
<script type="module" src="/src/main.ts"></script> |
||||
|
</body> |
||||
|
</html> |
||||
File diff suppressed because it is too large
@ -0,0 +1,29 @@ |
|||||
|
{ |
||||
|
"name": "health-mall", |
||||
|
"private": true, |
||||
|
"version": "1.0.0", |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"dev": "vite", |
||||
|
"build": "vue-tsc -b && vite build", |
||||
|
"preview": "vite preview" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@element-plus/icons-vue": "^2.3.2", |
||||
|
"axios": "^1.13.4", |
||||
|
"dayjs": "^1.11.19", |
||||
|
"element-plus": "^2.13.2", |
||||
|
"pinia": "^3.0.4", |
||||
|
"sass": "^1.97.3", |
||||
|
"vue": "^3.5.24", |
||||
|
"vue-router": "^4.6.4" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"@types/node": "^24.10.1", |
||||
|
"@vitejs/plugin-vue": "^6.0.1", |
||||
|
"@vue/tsconfig": "^0.8.1", |
||||
|
"typescript": "~5.9.3", |
||||
|
"vite": "^7.2.4", |
||||
|
"vue-tsc": "^3.1.4" |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,10 @@ |
|||||
|
<template> |
||||
|
<router-view /> |
||||
|
</template> |
||||
|
|
||||
|
<style> |
||||
|
html, body, #app { |
||||
|
height: 100%; |
||||
|
margin: 0; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,16 @@ |
|||||
|
import request from './request' |
||||
|
|
||||
|
// 登录
|
||||
|
export function loginApi(data: { phone?: string; email?: string; password: string }) { |
||||
|
return request.post('/api/auth/login', data) |
||||
|
} |
||||
|
|
||||
|
// 注册
|
||||
|
export function registerApi(data: { phone?: string; email?: string; password: string; code?: string }) { |
||||
|
return request.post('/api/auth/register', data) |
||||
|
} |
||||
|
|
||||
|
// 获取用户信息
|
||||
|
export function getUserInfoApi() { |
||||
|
return request.get('/api/user/profile') |
||||
|
} |
||||
@ -0,0 +1,180 @@ |
|||||
|
import request from './request' |
||||
|
|
||||
|
// ==================== 商品 ====================
|
||||
|
|
||||
|
/** 获取商品分类列表 */ |
||||
|
export function getCategoriesApi() { |
||||
|
return request.get('/api/mall/categories') |
||||
|
} |
||||
|
|
||||
|
/** 获取商品列表 */ |
||||
|
export function getProductsApi(params: { |
||||
|
page?: number |
||||
|
page_size?: number |
||||
|
category_id?: number |
||||
|
sort?: string |
||||
|
}) { |
||||
|
return request.get('/api/mall/products', { params }) |
||||
|
} |
||||
|
|
||||
|
/** 获取商品详情 */ |
||||
|
export function getProductDetailApi(id: number) { |
||||
|
return request.get(`/api/mall/products/${id}`) |
||||
|
} |
||||
|
|
||||
|
/** 搜索商品 */ |
||||
|
export function searchProductsApi(params: { |
||||
|
keyword: string |
||||
|
page?: number |
||||
|
page_size?: number |
||||
|
}) { |
||||
|
return request.get('/api/mall/products/search', { params }) |
||||
|
} |
||||
|
|
||||
|
/** 推荐/热门商品 */ |
||||
|
export function getFeaturedProductsApi(params?: { page?: number; page_size?: number }) { |
||||
|
return request.get('/api/mall/products/featured', { params }) |
||||
|
} |
||||
|
|
||||
|
/** 基于体质推荐商品 */ |
||||
|
export function getConstitutionProductsApi() { |
||||
|
return request.get('/api/mall/products/constitution-recommend') |
||||
|
} |
||||
|
|
||||
|
// ==================== 购物车 ====================
|
||||
|
|
||||
|
/** 获取购物车 */ |
||||
|
export function getCartApi() { |
||||
|
return request.get('/api/mall/cart') |
||||
|
} |
||||
|
|
||||
|
/** 添加到购物车 */ |
||||
|
export function addCartApi(data: { product_id: number; sku_id?: number; quantity?: number }) { |
||||
|
return request.post('/api/mall/cart', data) |
||||
|
} |
||||
|
|
||||
|
/** 更新购物车项 */ |
||||
|
export function updateCartApi(id: number, data: { quantity?: number; selected?: boolean }) { |
||||
|
return request.put(`/api/mall/cart/${id}`, data) |
||||
|
} |
||||
|
|
||||
|
/** 删除购物车项 */ |
||||
|
export function deleteCartApi(id: number) { |
||||
|
return request.delete(`/api/mall/cart/${id}`) |
||||
|
} |
||||
|
|
||||
|
/** 批量选中/取消 */ |
||||
|
export function batchSelectCartApi(data: { ids: number[]; selected: boolean }) { |
||||
|
return request.post('/api/mall/cart/batch-select', data) |
||||
|
} |
||||
|
|
||||
|
/** 清空购物车 */ |
||||
|
export function clearCartApi() { |
||||
|
return request.delete('/api/mall/cart/clear') |
||||
|
} |
||||
|
|
||||
|
// ==================== 收货地址 ====================
|
||||
|
|
||||
|
/** 获取地址列表 */ |
||||
|
export function getAddressesApi() { |
||||
|
return request.get('/api/mall/addresses') |
||||
|
} |
||||
|
|
||||
|
/** 获取单个地址 */ |
||||
|
export function getAddressApi(id: number) { |
||||
|
return request.get(`/api/mall/addresses/${id}`) |
||||
|
} |
||||
|
|
||||
|
/** 创建地址 */ |
||||
|
export function createAddressApi(data: { |
||||
|
receiver_name: string |
||||
|
phone: string |
||||
|
province: string |
||||
|
city: string |
||||
|
district: string |
||||
|
detail_addr: string |
||||
|
postal_code?: string |
||||
|
is_default?: boolean |
||||
|
tag?: string |
||||
|
}) { |
||||
|
return request.post('/api/mall/addresses', data) |
||||
|
} |
||||
|
|
||||
|
/** 更新地址 */ |
||||
|
export function updateAddressApi(id: number, data: { |
||||
|
receiver_name: string |
||||
|
phone: string |
||||
|
province: string |
||||
|
city: string |
||||
|
district: string |
||||
|
detail_addr: string |
||||
|
postal_code?: string |
||||
|
is_default?: boolean |
||||
|
tag?: string |
||||
|
}) { |
||||
|
return request.put(`/api/mall/addresses/${id}`, data) |
||||
|
} |
||||
|
|
||||
|
/** 删除地址 */ |
||||
|
export function deleteAddressApi(id: number) { |
||||
|
return request.delete(`/api/mall/addresses/${id}`) |
||||
|
} |
||||
|
|
||||
|
/** 设为默认地址 */ |
||||
|
export function setDefaultAddressApi(id: number) { |
||||
|
return request.put(`/api/mall/addresses/${id}/default`) |
||||
|
} |
||||
|
|
||||
|
// ==================== 订单 ====================
|
||||
|
|
||||
|
/** 订单预览(结算页) */ |
||||
|
export function previewOrderApi(data: { cart_item_ids: number[]; address_id?: number }) { |
||||
|
return request.post('/api/mall/orders/preview', data) |
||||
|
} |
||||
|
|
||||
|
/** 创建订单 */ |
||||
|
export function createOrderApi(data: { |
||||
|
address_id: number |
||||
|
cart_item_ids: number[] |
||||
|
points_used?: number |
||||
|
remark?: string |
||||
|
}) { |
||||
|
return request.post('/api/mall/orders', data) |
||||
|
} |
||||
|
|
||||
|
/** 获取订单列表 */ |
||||
|
export function getOrdersApi(params?: { page?: number; page_size?: number; status?: string }) { |
||||
|
return request.get('/api/mall/orders', { params }) |
||||
|
} |
||||
|
|
||||
|
/** 获取订单详情 */ |
||||
|
export function getOrderApi(id: number) { |
||||
|
return request.get(`/api/mall/orders/${id}`) |
||||
|
} |
||||
|
|
||||
|
/** 支付订单 */ |
||||
|
export function payOrderApi(id: number, data: { pay_method: string }) { |
||||
|
return request.post(`/api/mall/orders/${id}/pay`, data) |
||||
|
} |
||||
|
|
||||
|
/** 取消订单 */ |
||||
|
export function cancelOrderApi(id: number, data?: { reason?: string }) { |
||||
|
return request.post(`/api/mall/orders/${id}/cancel`, data || {}) |
||||
|
} |
||||
|
|
||||
|
/** 确认收货 */ |
||||
|
export function confirmReceiveApi(id: number) { |
||||
|
return request.post(`/api/mall/orders/${id}/receive`) |
||||
|
} |
||||
|
|
||||
|
// ==================== 会员 ====================
|
||||
|
|
||||
|
/** 获取会员信息 */ |
||||
|
export function getMemberInfoApi() { |
||||
|
return request.get('/api/mall/member/info') |
||||
|
} |
||||
|
|
||||
|
/** 获取积分记录 */ |
||||
|
export function getPointsRecordsApi(params?: { page?: number; page_size?: number }) { |
||||
|
return request.get('/api/mall/member/points/records', { params }) |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
import axios from 'axios' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import router from '@/router' |
||||
|
|
||||
|
// 创建 axios 实例
|
||||
|
const request = axios.create({ |
||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080', |
||||
|
timeout: 15000, |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json' |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 请求拦截器:自动携带 token(与健康助手共享 localStorage)
|
||||
|
request.interceptors.request.use( |
||||
|
(config) => { |
||||
|
const token = localStorage.getItem('token') |
||||
|
if (token) { |
||||
|
config.headers.Authorization = token |
||||
|
} |
||||
|
return config |
||||
|
}, |
||||
|
(error) => Promise.reject(error) |
||||
|
) |
||||
|
|
||||
|
// 响应拦截器:统一错误处理
|
||||
|
request.interceptors.response.use( |
||||
|
(response) => { |
||||
|
const data = response.data |
||||
|
// go-zero 直接返回数据(非 code/message/data 包装)
|
||||
|
return data |
||||
|
}, |
||||
|
(error) => { |
||||
|
const status = error.response?.status |
||||
|
const message = error.response?.data?.message || error.message |
||||
|
|
||||
|
if (status === 401) { |
||||
|
localStorage.removeItem('token') |
||||
|
localStorage.removeItem('user') |
||||
|
ElMessage.error('登录已过期,请重新登录') |
||||
|
// 跳转到本地登录页
|
||||
|
const currentPath = window.location.pathname + window.location.search |
||||
|
router.replace({ path: '/login', query: { redirect: currentPath } }) |
||||
|
} else if (status === 400) { |
||||
|
ElMessage.error(message || '请求参数错误') |
||||
|
} else if (status === 404) { |
||||
|
ElMessage.error('请求的资源不存在') |
||||
|
} else if (status === 500) { |
||||
|
ElMessage.error('服务器内部错误') |
||||
|
} else { |
||||
|
ElMessage.error(message || '网络异常,请稍后重试') |
||||
|
} |
||||
|
|
||||
|
return Promise.reject(error) |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
export default request |
||||
@ -0,0 +1,23 @@ |
|||||
|
import { createApp } from 'vue' |
||||
|
import { createPinia } from 'pinia' |
||||
|
import ElementPlus from 'element-plus' |
||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' |
||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue' |
||||
|
import 'element-plus/dist/index.css' |
||||
|
|
||||
|
import App from './App.vue' |
||||
|
import router from './router' |
||||
|
import './styles/index.scss' |
||||
|
|
||||
|
const app = createApp(App) |
||||
|
|
||||
|
// 注册所有图标
|
||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { |
||||
|
app.component(key, component) |
||||
|
} |
||||
|
|
||||
|
app.use(createPinia()) |
||||
|
app.use(router) |
||||
|
app.use(ElementPlus, { locale: zhCn }) |
||||
|
|
||||
|
app.mount('#app') |
||||
@ -0,0 +1,107 @@ |
|||||
|
import { createRouter, createWebHistory } from 'vue-router' |
||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
|
||||
|
const router = createRouter({ |
||||
|
history: createWebHistory(), |
||||
|
routes: [ |
||||
|
// 登录页(独立,不在 MallLayout 内)
|
||||
|
{ |
||||
|
path: '/login', |
||||
|
name: 'Login', |
||||
|
component: () => import('@/views/auth/LoginView.vue'), |
||||
|
meta: { public: true } |
||||
|
}, |
||||
|
// 商城主布局
|
||||
|
{ |
||||
|
path: '/', |
||||
|
component: () => import('@/views/layout/MallLayout.vue'), |
||||
|
children: [ |
||||
|
// ── 公开页面:自由浏览,无需登录 ──
|
||||
|
{ |
||||
|
path: '', |
||||
|
name: 'MallHome', |
||||
|
component: () => import('@/views/mall/MallHomeView.vue'), |
||||
|
meta: { tabBar: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'category', |
||||
|
name: 'MallCategory', |
||||
|
component: () => import('@/views/mall/CategoryView.vue'), |
||||
|
meta: { tabBar: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'category/:id', |
||||
|
name: 'MallCategoryDetail', |
||||
|
component: () => import('@/views/mall/CategoryView.vue') |
||||
|
}, |
||||
|
{ |
||||
|
path: 'product/:id', |
||||
|
name: 'MallProductDetail', |
||||
|
component: () => import('@/views/mall/ProductDetailView.vue') |
||||
|
}, |
||||
|
{ |
||||
|
path: 'search', |
||||
|
name: 'MallSearch', |
||||
|
component: () => import('@/views/mall/SearchView.vue') |
||||
|
}, |
||||
|
// ── 需要登录:涉及用户数据的页面 ──
|
||||
|
{ |
||||
|
path: 'cart', |
||||
|
name: 'MallCart', |
||||
|
component: () => import('@/views/mall/CartView.vue'), |
||||
|
meta: { tabBar: true, requiresAuth: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'checkout', |
||||
|
name: 'MallCheckout', |
||||
|
component: () => import('@/views/mall/CheckoutView.vue'), |
||||
|
meta: { requiresAuth: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'orders', |
||||
|
name: 'MallOrders', |
||||
|
component: () => import('@/views/mall/OrderListView.vue'), |
||||
|
meta: { requiresAuth: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'orders/:id', |
||||
|
name: 'MallOrderDetail', |
||||
|
component: () => import('@/views/mall/OrderDetailView.vue'), |
||||
|
meta: { requiresAuth: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'address', |
||||
|
name: 'MallAddress', |
||||
|
component: () => import('@/views/mall/AddressListView.vue'), |
||||
|
meta: { requiresAuth: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: 'member', |
||||
|
name: 'MallMember', |
||||
|
component: () => import('@/views/mall/MemberView.vue'), |
||||
|
meta: { tabBar: true, requiresAuth: true } |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}) |
||||
|
|
||||
|
// 路由守卫
|
||||
|
router.beforeEach((to, _from, next) => { |
||||
|
const authStore = useAuthStore() |
||||
|
|
||||
|
// 登录页:已登录则跳首页
|
||||
|
if (to.meta.public) { |
||||
|
return authStore.isLoggedIn ? next('/') : next() |
||||
|
} |
||||
|
|
||||
|
// 需要登录的页面:未登录则跳登录页
|
||||
|
if (to.meta.requiresAuth && !authStore.isLoggedIn) { |
||||
|
return next({ path: '/login', query: { redirect: to.fullPath } }) |
||||
|
} |
||||
|
|
||||
|
// 其余页面(首页、分类、商品详情、搜索)公开访问
|
||||
|
next() |
||||
|
}) |
||||
|
|
||||
|
export default router |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { defineStore } from 'pinia' |
||||
|
import { ref, computed } from 'vue' |
||||
|
import type { User } from '@/types' |
||||
|
|
||||
|
export const useAuthStore = defineStore('auth', () => { |
||||
|
const user = ref<User | null>(null) |
||||
|
const token = ref<string | null>(localStorage.getItem('token')) |
||||
|
|
||||
|
const isLoggedIn = computed(() => !!token.value) |
||||
|
|
||||
|
function setToken(t: string) { |
||||
|
token.value = t |
||||
|
localStorage.setItem('token', t) |
||||
|
} |
||||
|
|
||||
|
function setUser(userData: User) { |
||||
|
user.value = userData |
||||
|
localStorage.setItem('user', JSON.stringify(userData)) |
||||
|
} |
||||
|
|
||||
|
function logout() { |
||||
|
user.value = null |
||||
|
token.value = null |
||||
|
localStorage.removeItem('token') |
||||
|
localStorage.removeItem('user') |
||||
|
} |
||||
|
|
||||
|
// 初始化时从 localStorage 恢复用户信息(与健康助手共享)
|
||||
|
function init() { |
||||
|
const savedUser = localStorage.getItem('user') |
||||
|
if (savedUser && token.value) { |
||||
|
user.value = JSON.parse(savedUser) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
init() |
||||
|
|
||||
|
return { user, token, isLoggedIn, setToken, setUser, logout } |
||||
|
}) |
||||
@ -0,0 +1,23 @@ |
|||||
|
import { defineStore } from 'pinia' |
||||
|
import { ref } from 'vue' |
||||
|
import type { ConstitutionResult } from '@/types' |
||||
|
|
||||
|
/** |
||||
|
* 体质 store - 仅用于商城的体质推荐卡片展示 |
||||
|
* 数据来源于 localStorage(与健康助手共享) |
||||
|
*/ |
||||
|
export const useConstitutionStore = defineStore('constitution', () => { |
||||
|
const result = ref<ConstitutionResult | null>(null) |
||||
|
|
||||
|
// 初始化时从 localStorage 恢复(健康助手写入的体质数据)
|
||||
|
function init() { |
||||
|
const saved = localStorage.getItem('constitution_result') |
||||
|
if (saved) { |
||||
|
result.value = JSON.parse(saved) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
init() |
||||
|
|
||||
|
return { result } |
||||
|
}) |
||||
@ -0,0 +1,113 @@ |
|||||
|
import { defineStore } from 'pinia' |
||||
|
import { ref, computed } from 'vue' |
||||
|
import type { CartItem, CartResp, ProductCategory } from '@/types' |
||||
|
import { |
||||
|
getCartApi, |
||||
|
addCartApi, |
||||
|
updateCartApi, |
||||
|
deleteCartApi, |
||||
|
batchSelectCartApi, |
||||
|
clearCartApi, |
||||
|
getCategoriesApi |
||||
|
} from '@/api/mall' |
||||
|
|
||||
|
export const useMallStore = defineStore('mall', () => { |
||||
|
// ===== 购物车 =====
|
||||
|
const cartItems = ref<CartItem[]>([]) |
||||
|
const cartTotalCount = ref(0) |
||||
|
const cartSelectedCount = ref(0) |
||||
|
const cartTotalAmount = ref(0) |
||||
|
const cartLoading = ref(false) |
||||
|
|
||||
|
// 购物车数量角标
|
||||
|
const cartBadge = computed(() => cartTotalCount.value > 0 ? cartTotalCount.value : 0) |
||||
|
|
||||
|
// 已选中商品
|
||||
|
const selectedItems = computed(() => cartItems.value.filter(item => item.selected)) |
||||
|
|
||||
|
// 全选状态
|
||||
|
const isAllSelected = computed(() => |
||||
|
cartItems.value.length > 0 && cartItems.value.every(item => item.selected) |
||||
|
) |
||||
|
|
||||
|
async function fetchCart() { |
||||
|
cartLoading.value = true |
||||
|
try { |
||||
|
const data = await getCartApi() as unknown as CartResp |
||||
|
cartItems.value = data.items || [] |
||||
|
cartTotalCount.value = data.total_count |
||||
|
cartSelectedCount.value = data.selected_count |
||||
|
cartTotalAmount.value = data.total_amount |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理
|
||||
|
} finally { |
||||
|
cartLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function addToCart(productId: number, skuId?: number, quantity = 1) { |
||||
|
await addCartApi({ product_id: productId, sku_id: skuId, quantity }) |
||||
|
await fetchCart() |
||||
|
} |
||||
|
|
||||
|
async function updateCartItem(id: number, data: { quantity?: number; selected?: boolean }) { |
||||
|
await updateCartApi(id, data) |
||||
|
await fetchCart() |
||||
|
} |
||||
|
|
||||
|
async function removeCartItem(id: number) { |
||||
|
await deleteCartApi(id) |
||||
|
await fetchCart() |
||||
|
} |
||||
|
|
||||
|
async function toggleSelectAll(selected: boolean) { |
||||
|
const ids = cartItems.value.map(item => item.id) |
||||
|
if (ids.length > 0) { |
||||
|
await batchSelectCartApi({ ids, selected }) |
||||
|
await fetchCart() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function clearCart() { |
||||
|
await clearCartApi() |
||||
|
await fetchCart() |
||||
|
} |
||||
|
|
||||
|
// ===== 分类 =====
|
||||
|
const categories = ref<ProductCategory[]>([]) |
||||
|
const categoriesLoading = ref(false) |
||||
|
|
||||
|
async function fetchCategories() { |
||||
|
categoriesLoading.value = true |
||||
|
try { |
||||
|
const data = await getCategoriesApi() as unknown as { categories: ProductCategory[] } |
||||
|
categories.value = data.categories || [] |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理
|
||||
|
} finally { |
||||
|
categoriesLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
// 购物车
|
||||
|
cartItems, |
||||
|
cartTotalCount, |
||||
|
cartSelectedCount, |
||||
|
cartTotalAmount, |
||||
|
cartLoading, |
||||
|
cartBadge, |
||||
|
selectedItems, |
||||
|
isAllSelected, |
||||
|
fetchCart, |
||||
|
addToCart, |
||||
|
updateCartItem, |
||||
|
removeCartItem, |
||||
|
toggleSelectAll, |
||||
|
clearCart, |
||||
|
// 分类
|
||||
|
categories, |
||||
|
categoriesLoading, |
||||
|
fetchCategories |
||||
|
} |
||||
|
}) |
||||
@ -0,0 +1,65 @@ |
|||||
|
import { defineStore } from 'pinia' |
||||
|
import { ref, computed } from 'vue' |
||||
|
import type { MemberInfo, PointsRecord, PageInfo } from '@/types' |
||||
|
import { MEMBER_LEVEL_MAP } from '@/types' |
||||
|
import { getMemberInfoApi, getPointsRecordsApi } from '@/api/mall' |
||||
|
|
||||
|
export const useMemberStore = defineStore('member', () => { |
||||
|
const memberInfo = ref<MemberInfo | null>(null) |
||||
|
const pointsRecords = ref<PointsRecord[]>([]) |
||||
|
const pageInfo = ref<PageInfo>({ total: 0, page: 1, page_size: 10 }) |
||||
|
const loading = ref(false) |
||||
|
|
||||
|
const levelInfo = computed(() => { |
||||
|
const level = memberInfo.value?.level || 'normal' |
||||
|
return MEMBER_LEVEL_MAP[level] || MEMBER_LEVEL_MAP.normal |
||||
|
}) |
||||
|
|
||||
|
// 升级进度 0~100
|
||||
|
const upgradeProgress = computed(() => { |
||||
|
if (!memberInfo.value || !memberInfo.value.next_level_spent) return 100 |
||||
|
const nextSpent = memberInfo.value.next_level_spent |
||||
|
const totalSpent = memberInfo.value.total_spent |
||||
|
// 下一等级所需累计消费
|
||||
|
const targetSpent = totalSpent + nextSpent |
||||
|
return Math.min(Math.round((totalSpent / targetSpent) * 100), 100) |
||||
|
}) |
||||
|
|
||||
|
async function fetchMemberInfo() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
memberInfo.value = await getMemberInfoApi() as unknown as MemberInfo |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理
|
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function fetchPointsRecords(params?: { page?: number; page_size?: number }) { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await getPointsRecordsApi(params) as unknown as { |
||||
|
records: PointsRecord[] |
||||
|
page_info: PageInfo |
||||
|
} |
||||
|
pointsRecords.value = data.records || [] |
||||
|
pageInfo.value = data.page_info |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理
|
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
memberInfo, |
||||
|
pointsRecords, |
||||
|
pageInfo, |
||||
|
loading, |
||||
|
levelInfo, |
||||
|
upgradeProgress, |
||||
|
fetchMemberInfo, |
||||
|
fetchPointsRecords |
||||
|
} |
||||
|
}) |
||||
@ -0,0 +1,87 @@ |
|||||
|
import { defineStore } from 'pinia' |
||||
|
import { ref } from 'vue' |
||||
|
import type { Order, OrderPreview, PageInfo } from '@/types' |
||||
|
import { |
||||
|
getOrdersApi, |
||||
|
getOrderApi, |
||||
|
previewOrderApi, |
||||
|
createOrderApi, |
||||
|
payOrderApi, |
||||
|
cancelOrderApi, |
||||
|
confirmReceiveApi |
||||
|
} from '@/api/mall' |
||||
|
|
||||
|
export const useOrderStore = defineStore('order', () => { |
||||
|
const orders = ref<Order[]>([]) |
||||
|
const currentOrder = ref<Order | null>(null) |
||||
|
const orderPreview = ref<OrderPreview | null>(null) |
||||
|
const pageInfo = ref<PageInfo>({ total: 0, page: 1, page_size: 10 }) |
||||
|
const loading = ref(false) |
||||
|
|
||||
|
async function fetchOrders(params?: { page?: number; page_size?: number; status?: string }) { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await getOrdersApi(params) as unknown as { orders: Order[]; page_info: PageInfo } |
||||
|
orders.value = data.orders || [] |
||||
|
pageInfo.value = data.page_info |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理
|
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function fetchOrder(id: number) { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
currentOrder.value = await getOrderApi(id) as unknown as Order |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理
|
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function preview(cartItemIds: number[], addressId?: number) { |
||||
|
const data = await previewOrderApi({ cart_item_ids: cartItemIds, address_id: addressId }) as unknown as OrderPreview |
||||
|
orderPreview.value = data |
||||
|
return data |
||||
|
} |
||||
|
|
||||
|
async function create(data: { |
||||
|
address_id: number |
||||
|
cart_item_ids: number[] |
||||
|
points_used?: number |
||||
|
remark?: string |
||||
|
}) { |
||||
|
const order = await createOrderApi(data) as unknown as Order |
||||
|
return order |
||||
|
} |
||||
|
|
||||
|
async function pay(id: number, payMethod: string) { |
||||
|
await payOrderApi(id, { pay_method: payMethod }) |
||||
|
} |
||||
|
|
||||
|
async function cancel(id: number, reason?: string) { |
||||
|
await cancelOrderApi(id, { reason }) |
||||
|
} |
||||
|
|
||||
|
async function confirmReceive(id: number) { |
||||
|
await confirmReceiveApi(id) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
orders, |
||||
|
currentOrder, |
||||
|
orderPreview, |
||||
|
pageInfo, |
||||
|
loading, |
||||
|
fetchOrders, |
||||
|
fetchOrder, |
||||
|
preview, |
||||
|
create, |
||||
|
pay, |
||||
|
cancel, |
||||
|
confirmReceive |
||||
|
} |
||||
|
}) |
||||
@ -0,0 +1,51 @@ |
|||||
|
// 商城主题色 |
||||
|
$primary-color: #52C41A; |
||||
|
$primary-light: #F6FFED; |
||||
|
$price-color: #FF4D4F; |
||||
|
$warning-color: #F59E0B; |
||||
|
$text-primary: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$bg-color: #F5F5F5; |
||||
|
$border-color: #EBEDF0; |
||||
|
|
||||
|
// 全局样式 |
||||
|
* { |
||||
|
margin: 0; |
||||
|
padding: 0; |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
|
||||
|
html, body, #app { |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
body { |
||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; |
||||
|
color: $text-primary; |
||||
|
background-color: $bg-color; |
||||
|
-webkit-font-smoothing: antialiased; |
||||
|
-moz-osx-font-smoothing: grayscale; |
||||
|
// H5 禁止缩放相关 |
||||
|
-webkit-text-size-adjust: 100%; |
||||
|
} |
||||
|
|
||||
|
// Element Plus 主题覆盖 — 使用商城绿色主题 |
||||
|
:root { |
||||
|
--el-color-primary: #{$primary-color}; |
||||
|
--el-color-success: #{$primary-color}; |
||||
|
--el-color-danger: #{$price-color}; |
||||
|
} |
||||
|
|
||||
|
// 通用工具类 |
||||
|
.page-container { |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
padding: 12px; |
||||
|
} |
||||
|
|
||||
|
// 禁止用户选择文本(H5 优化) |
||||
|
.no-select { |
||||
|
-webkit-user-select: none; |
||||
|
user-select: none; |
||||
|
} |
||||
@ -0,0 +1,232 @@ |
|||||
|
// 用户类型(与健康助手共享)
|
||||
|
export interface User { |
||||
|
id: number |
||||
|
phone: string |
||||
|
nickname: string |
||||
|
avatar: string |
||||
|
surveyCompleted: boolean |
||||
|
} |
||||
|
|
||||
|
// 体质类型(商城需要用于推荐展示)
|
||||
|
export type ConstitutionType = |
||||
|
| 'pinghe' | 'qixu' | 'yangxu' | 'yinxu' | 'tanshi' |
||||
|
| 'shire' | 'xueyu' | 'qiyu' | 'tebing' |
||||
|
|
||||
|
// 体质评估结果(仅用于体质推荐卡片展示)
|
||||
|
export interface ConstitutionResult { |
||||
|
primaryType: ConstitutionType |
||||
|
scores: Record<ConstitutionType, number> |
||||
|
description: string |
||||
|
suggestions: string[] |
||||
|
assessedAt: string |
||||
|
} |
||||
|
|
||||
|
// 体质名称映射
|
||||
|
export const constitutionNames: Record<ConstitutionType, string> = { |
||||
|
pinghe: '平和质', |
||||
|
qixu: '气虚质', |
||||
|
yangxu: '阳虚质', |
||||
|
yinxu: '阴虚质', |
||||
|
tanshi: '痰湿质', |
||||
|
shire: '湿热质', |
||||
|
xueyu: '血瘀质', |
||||
|
qiyu: '气郁质', |
||||
|
tebing: '特禀质' |
||||
|
} |
||||
|
|
||||
|
// ==================== 商城类型 ====================
|
||||
|
|
||||
|
// 商品分类
|
||||
|
export interface ProductCategory { |
||||
|
id: number |
||||
|
name: string |
||||
|
parent_id: number |
||||
|
icon: string |
||||
|
description: string |
||||
|
sort: number |
||||
|
} |
||||
|
|
||||
|
// 商品 SKU
|
||||
|
export interface ProductSku { |
||||
|
id: number |
||||
|
product_id: number |
||||
|
sku_code: string |
||||
|
name: string |
||||
|
attributes: string |
||||
|
price: number |
||||
|
stock: number |
||||
|
image: string |
||||
|
} |
||||
|
|
||||
|
// 商品详情
|
||||
|
export interface ProductDetail { |
||||
|
id: number |
||||
|
category_id: number |
||||
|
name: string |
||||
|
description: string |
||||
|
main_image: string |
||||
|
images: string[] |
||||
|
price: number |
||||
|
original_price: number |
||||
|
stock: number |
||||
|
sales_count: number |
||||
|
is_featured: boolean |
||||
|
constitution_types: string[] |
||||
|
health_tags: string[] |
||||
|
efficacy: string |
||||
|
ingredients: string |
||||
|
usage: string |
||||
|
contraindications: string |
||||
|
skus: ProductSku[] |
||||
|
} |
||||
|
|
||||
|
// 商品列表项(简要信息)
|
||||
|
export interface ProductListItem { |
||||
|
id: number |
||||
|
name: string |
||||
|
description: string |
||||
|
main_image: string |
||||
|
price: number |
||||
|
original_price: number |
||||
|
sales_count: number |
||||
|
stock: number |
||||
|
is_featured: boolean |
||||
|
constitution_types: string[] |
||||
|
health_tags: string[] |
||||
|
} |
||||
|
|
||||
|
// 购物车项
|
||||
|
export interface CartItem { |
||||
|
id: number |
||||
|
product_id: number |
||||
|
sku_id: number |
||||
|
product_name: string |
||||
|
sku_name: string |
||||
|
image: string |
||||
|
price: number |
||||
|
quantity: number |
||||
|
selected: boolean |
||||
|
stock: number |
||||
|
} |
||||
|
|
||||
|
// 购物车响应
|
||||
|
export interface CartResp { |
||||
|
items: CartItem[] |
||||
|
total_count: number |
||||
|
selected_count: number |
||||
|
total_amount: number |
||||
|
} |
||||
|
|
||||
|
// 收货地址
|
||||
|
export interface Address { |
||||
|
id: number |
||||
|
receiver_name: string |
||||
|
phone: string |
||||
|
province: string |
||||
|
city: string |
||||
|
district: string |
||||
|
detail_addr: string |
||||
|
postal_code: string |
||||
|
is_default: boolean |
||||
|
tag: string |
||||
|
} |
||||
|
|
||||
|
// 订单商品项
|
||||
|
export interface OrderItem { |
||||
|
id: number |
||||
|
product_id: number |
||||
|
sku_id: number |
||||
|
product_name: string |
||||
|
sku_name: string |
||||
|
image: string |
||||
|
price: number |
||||
|
quantity: number |
||||
|
total_amount: number |
||||
|
} |
||||
|
|
||||
|
// 订单
|
||||
|
export interface Order { |
||||
|
id: number |
||||
|
order_no: string |
||||
|
status: string |
||||
|
total_amount: number |
||||
|
discount_amount: number |
||||
|
shipping_fee: number |
||||
|
pay_amount: number |
||||
|
points_used: number |
||||
|
points_earned: number |
||||
|
pay_method: string |
||||
|
pay_time: string |
||||
|
ship_time: string |
||||
|
receive_time: string |
||||
|
receiver_name: string |
||||
|
receiver_phone: string |
||||
|
receiver_addr: string |
||||
|
shipping_company: string |
||||
|
tracking_no: string |
||||
|
remark: string |
||||
|
cancel_reason: string |
||||
|
items: OrderItem[] |
||||
|
created_at: string |
||||
|
} |
||||
|
|
||||
|
// 订单预览
|
||||
|
export interface OrderPreview { |
||||
|
items: CartItem[] |
||||
|
total_amount: number |
||||
|
discount_amount: number |
||||
|
shipping_fee: number |
||||
|
pay_amount: number |
||||
|
max_points_use: number |
||||
|
points_discount: number |
||||
|
} |
||||
|
|
||||
|
// 会员信息
|
||||
|
export interface MemberInfo { |
||||
|
level: string |
||||
|
level_name: string |
||||
|
total_spent: number |
||||
|
points: number |
||||
|
member_since: string |
||||
|
next_level: string |
||||
|
next_level_spent: number |
||||
|
discount: number |
||||
|
points_multiplier: number |
||||
|
free_shipping_min: number |
||||
|
} |
||||
|
|
||||
|
// 积分记录
|
||||
|
export interface PointsRecord { |
||||
|
id: number |
||||
|
type: string |
||||
|
points: number |
||||
|
balance: number |
||||
|
source: string |
||||
|
remark: string |
||||
|
created_at: string |
||||
|
} |
||||
|
|
||||
|
// 分页信息
|
||||
|
export interface PageInfo { |
||||
|
total: number |
||||
|
page: number |
||||
|
page_size: number |
||||
|
} |
||||
|
|
||||
|
// 订单状态映射
|
||||
|
export const ORDER_STATUS_MAP: Record<string, { label: string; color: string }> = { |
||||
|
pending: { label: '待支付', color: '#F59E0B' }, |
||||
|
paid: { label: '待发货', color: '#3B82F6' }, |
||||
|
shipped: { label: '待收货', color: '#8B5CF6' }, |
||||
|
completed: { label: '已完成', color: '#10B981' }, |
||||
|
cancelled: { label: '已取消', color: '#9CA3AF' }, |
||||
|
refunding: { label: '退款中', color: '#EF4444' } |
||||
|
} |
||||
|
|
||||
|
// 会员等级映射
|
||||
|
export const MEMBER_LEVEL_MAP: Record<string, { label: string; color: string; icon: string }> = { |
||||
|
normal: { label: '普通会员', color: '#9CA3AF', icon: '👤' }, |
||||
|
silver: { label: '银卡会员', color: '#94A3B8', icon: '🥈' }, |
||||
|
gold: { label: '金卡会员', color: '#F59E0B', icon: '🥇' }, |
||||
|
diamond: { label: '钻石会员', color: '#8B5CF6', icon: '💎' } |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
/** |
||||
|
* 认证工具函数 |
||||
|
* 用于需要登录的操作前检查登录状态 |
||||
|
*/ |
||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { ElMessageBox } from 'element-plus' |
||||
|
|
||||
|
/** |
||||
|
* 检查是否已登录,未登录则弹窗引导 |
||||
|
* @returns true=已登录可继续, false=未登录已引导 |
||||
|
*/ |
||||
|
export function useAuthCheck() { |
||||
|
const authStore = useAuthStore() |
||||
|
const router = useRouter() |
||||
|
|
||||
|
async function requireAuth(message = '登录后即可享受完整购物体验'): Promise<boolean> { |
||||
|
if (authStore.isLoggedIn) return true |
||||
|
|
||||
|
try { |
||||
|
await ElMessageBox.confirm(message, '请先登录', { |
||||
|
confirmButtonText: '去登录', |
||||
|
cancelButtonText: '再看看', |
||||
|
type: 'info', |
||||
|
customClass: 'auth-confirm-dialog' |
||||
|
}) |
||||
|
// 用户点击"去登录"
|
||||
|
const currentPath = router.currentRoute.value.fullPath |
||||
|
router.push({ path: '/login', query: { redirect: currentPath } }) |
||||
|
} catch { |
||||
|
// 用户点击"再看看",不做操作
|
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
return { requireAuth, isLoggedIn: authStore.isLoggedIn } |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
/** |
||||
|
* 健康AI跳转工具 |
||||
|
* 商城 → 健康AI 的跨项目跳转 |
||||
|
*/ |
||||
|
|
||||
|
const HEALTH_AI_BASE_URL = import.meta.env.VITE_HEALTH_AI_URL || 'http://localhost:5173' |
||||
|
|
||||
|
interface JumpParams { |
||||
|
page: 'chat' | 'constitution' | 'constitution/test' | 'constitution/result' |
||||
|
productId?: number |
||||
|
productName?: string |
||||
|
source?: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 跳转到健康AI页面 |
||||
|
*/ |
||||
|
export function jumpToHealthAI(params: JumpParams) { |
||||
|
const query = new URLSearchParams() |
||||
|
query.set('source', params.source || 'mall') |
||||
|
|
||||
|
if (params.productId) { |
||||
|
query.set('product_id', String(params.productId)) |
||||
|
} |
||||
|
if (params.productName) { |
||||
|
query.set('product_name', params.productName) |
||||
|
} |
||||
|
|
||||
|
const url = `${HEALTH_AI_BASE_URL}/${params.page}?${query.toString()}` |
||||
|
|
||||
|
// 同域直接跳转,跨域新窗口打开
|
||||
|
if (url.startsWith(window.location.origin)) { |
||||
|
window.location.href = url |
||||
|
} else { |
||||
|
window.open(url, '_blank') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 跳转到AI咨询并携带产品信息 |
||||
|
*/ |
||||
|
export function consultAIAboutProduct(productId: number, productName: string) { |
||||
|
jumpToHealthAI({ |
||||
|
page: 'chat', |
||||
|
productId, |
||||
|
productName |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 跳转到体质测试 |
||||
|
*/ |
||||
|
export function goToConstitutionTest() { |
||||
|
jumpToHealthAI({ page: 'constitution/test' }) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 跳转到体质结果 |
||||
|
*/ |
||||
|
export function goToConstitutionResult() { |
||||
|
jumpToHealthAI({ page: 'constitution/result' }) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 跳转到健康助手首页 |
||||
|
*/ |
||||
|
export function goToHealthAI() { |
||||
|
const url = HEALTH_AI_BASE_URL |
||||
|
if (url.startsWith(window.location.origin)) { |
||||
|
window.location.href = url |
||||
|
} else { |
||||
|
window.open(url, '_blank') |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,265 @@ |
|||||
|
<template> |
||||
|
<div class="login-page"> |
||||
|
<div class="login-header"> |
||||
|
<div class="logo"> |
||||
|
<el-icon :size="48" color="#fff"><ShoppingCart /></el-icon> |
||||
|
</div> |
||||
|
<h1>健康商城</h1> |
||||
|
<p class="subtitle">专注体质养生 · 科学健康购物</p> |
||||
|
</div> |
||||
|
|
||||
|
<el-card class="login-card"> |
||||
|
<el-form |
||||
|
ref="formRef" |
||||
|
:model="form" |
||||
|
:rules="rules" |
||||
|
label-position="top" |
||||
|
size="large" |
||||
|
> |
||||
|
<el-form-item label="手机号" prop="phone"> |
||||
|
<el-input |
||||
|
v-model="form.phone" |
||||
|
placeholder="请输入手机号" |
||||
|
:prefix-icon="User" |
||||
|
maxlength="11" |
||||
|
@keyup.enter="focusPassword" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="密码" prop="password"> |
||||
|
<el-input |
||||
|
ref="passwordRef" |
||||
|
v-model="form.password" |
||||
|
type="password" |
||||
|
placeholder="请输入密码" |
||||
|
:prefix-icon="Lock" |
||||
|
show-password |
||||
|
@keyup.enter="handleLogin" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item> |
||||
|
<el-button |
||||
|
type="primary" |
||||
|
:loading="loading" |
||||
|
class="login-btn" |
||||
|
@click="handleLogin" |
||||
|
> |
||||
|
登录 |
||||
|
</el-button> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
|
||||
|
<div class="login-hint"> |
||||
|
测试账号:13800138000 / 123456 |
||||
|
</div> |
||||
|
|
||||
|
<div class="login-links"> |
||||
|
<span class="health-ai-link" @click="goToHealthAI"> |
||||
|
<el-icon :size="14"><FirstAidKit /></el-icon> |
||||
|
前往健康AI助手 |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="agreement"> |
||||
|
登录即表示您同意 |
||||
|
<el-link type="primary">《用户协议》</el-link> |
||||
|
和 |
||||
|
<el-link type="primary">《隐私政策》</el-link> |
||||
|
</div> |
||||
|
</el-card> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive } from 'vue' |
||||
|
import { useRouter, useRoute } from 'vue-router' |
||||
|
import { User, Lock, ShoppingCart, FirstAidKit } from '@element-plus/icons-vue' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import type { FormInstance, FormRules } from 'element-plus' |
||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
import { loginApi } from '@/api/auth' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const route = useRoute() |
||||
|
const authStore = useAuthStore() |
||||
|
const formRef = ref<FormInstance>() |
||||
|
const passwordRef = ref() |
||||
|
const loading = ref(false) |
||||
|
|
||||
|
const form = reactive({ |
||||
|
phone: '13800138000', |
||||
|
password: '' |
||||
|
}) |
||||
|
|
||||
|
const rules: FormRules = { |
||||
|
phone: [ |
||||
|
{ required: true, message: '请输入手机号', trigger: 'blur' }, |
||||
|
{ pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' } |
||||
|
], |
||||
|
password: [ |
||||
|
{ required: true, message: '请输入密码', trigger: 'blur' }, |
||||
|
{ min: 6, message: '密码至少6位', trigger: 'blur' } |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
function focusPassword() { |
||||
|
passwordRef.value?.focus() |
||||
|
} |
||||
|
|
||||
|
async function handleLogin() { |
||||
|
const valid = await formRef.value?.validate().catch(() => false) |
||||
|
if (!valid) return |
||||
|
|
||||
|
loading.value = true |
||||
|
try { |
||||
|
const resp = await loginApi({ phone: form.phone, password: form.password }) as any |
||||
|
|
||||
|
// 后端返回格式:{ code: 0, data: { token, user } } 或直接 { token, user } |
||||
|
const data = resp?.data || resp |
||||
|
const token = data?.token |
||||
|
const user = data?.user |
||||
|
|
||||
|
if (!token) { |
||||
|
ElMessage.error('登录失败,请检查账号密码') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 保存到 store 和 localStorage(与健康助手共享) |
||||
|
authStore.setToken(token) |
||||
|
if (user) { |
||||
|
authStore.setUser(user) |
||||
|
} |
||||
|
|
||||
|
ElMessage.success('登录成功') |
||||
|
|
||||
|
// 跳转到之前的页面或首页 |
||||
|
const redirect = (route.query.redirect as string) || '/' |
||||
|
router.replace(redirect) |
||||
|
} catch (err: any) { |
||||
|
const msg = err?.response?.data?.message || err?.message || '登录失败' |
||||
|
// 拦截器可能已经弹出错误提示,这里不重复 |
||||
|
if (!msg.includes('已过期')) { |
||||
|
ElMessage.error(msg) |
||||
|
} |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function goToHealthAI() { |
||||
|
const healthUrl = import.meta.env.VITE_HEALTH_AI_URL || 'http://localhost:5173' |
||||
|
window.open(healthUrl, '_blank') |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
.login-page { |
||||
|
min-height: 100vh; |
||||
|
background: linear-gradient(135deg, #52C41A 0%, #389E0D 50%, #237804 100%); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
padding: 60px 20px; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
.login-header { |
||||
|
text-align: center; |
||||
|
margin-bottom: 36px; |
||||
|
|
||||
|
.logo { |
||||
|
width: 80px; |
||||
|
height: 80px; |
||||
|
background: rgba(255, 255, 255, 0.2); |
||||
|
border-radius: 20px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
margin: 0 auto 16px; |
||||
|
backdrop-filter: blur(10px); |
||||
|
} |
||||
|
|
||||
|
h1 { |
||||
|
color: #fff; |
||||
|
font-size: 28px; |
||||
|
font-weight: 700; |
||||
|
margin: 0 0 8px; |
||||
|
letter-spacing: 2px; |
||||
|
} |
||||
|
|
||||
|
.subtitle { |
||||
|
color: rgba(255, 255, 255, 0.85); |
||||
|
font-size: 14px; |
||||
|
margin: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.login-card { |
||||
|
width: 100%; |
||||
|
max-width: 400px; |
||||
|
border-radius: 16px; |
||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); |
||||
|
|
||||
|
:deep(.el-card__body) { |
||||
|
padding: 28px 24px; |
||||
|
} |
||||
|
|
||||
|
.login-btn { |
||||
|
width: 100%; |
||||
|
height: 48px; |
||||
|
border-radius: 24px; |
||||
|
font-size: 17px; |
||||
|
font-weight: 600; |
||||
|
background: #52C41A; |
||||
|
border-color: #52C41A; |
||||
|
letter-spacing: 2px; |
||||
|
|
||||
|
&:hover, &:focus { |
||||
|
background: #389E0D; |
||||
|
border-color: #389E0D; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.login-hint { |
||||
|
text-align: center; |
||||
|
font-size: 12px; |
||||
|
color: #9CA3AF; |
||||
|
margin-bottom: 16px; |
||||
|
padding: 8px 12px; |
||||
|
background: #F9FAFB; |
||||
|
border-radius: 8px; |
||||
|
} |
||||
|
|
||||
|
.login-links { |
||||
|
text-align: center; |
||||
|
margin-bottom: 16px; |
||||
|
|
||||
|
.health-ai-link { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
font-size: 14px; |
||||
|
color: #52C41A; |
||||
|
cursor: pointer; |
||||
|
transition: color 0.2s; |
||||
|
|
||||
|
&:hover { |
||||
|
color: #389E0D; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.agreement { |
||||
|
text-align: center; |
||||
|
font-size: 12px; |
||||
|
color: #9CA3AF; |
||||
|
line-height: 1.8; |
||||
|
|
||||
|
:deep(.el-link) { |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,241 @@ |
|||||
|
<template> |
||||
|
<div class="mall-layout"> |
||||
|
<!-- 顶部导航栏 --> |
||||
|
<header class="mall-header"> |
||||
|
<div class="header-left"> |
||||
|
<el-icon v-if="showBack" class="back-btn" @click="goBack"><ArrowLeft /></el-icon> |
||||
|
<span class="header-title">{{ pageTitle }}</span> |
||||
|
</div> |
||||
|
<div class="header-right"> |
||||
|
<el-icon class="header-icon" @click="router.push('/search')"><Search /></el-icon> |
||||
|
<el-badge :value="mallStore.cartBadge || undefined" :hidden="!mallStore.cartBadge" class="cart-badge"> |
||||
|
<el-icon class="header-icon" @click="router.push('/cart')"><ShoppingCart /></el-icon> |
||||
|
</el-badge> |
||||
|
</div> |
||||
|
</header> |
||||
|
|
||||
|
<!-- 主内容区 --> |
||||
|
<main class="mall-content"> |
||||
|
<router-view /> |
||||
|
</main> |
||||
|
|
||||
|
<!-- 底部Tab栏 --> |
||||
|
<nav v-if="showTabBar" class="mall-tabbar"> |
||||
|
<div |
||||
|
v-for="tab in tabs" |
||||
|
:key="tab.path" |
||||
|
class="tab-item" |
||||
|
:class="{ active: isActiveTab(tab.path) }" |
||||
|
@click="router.push(tab.path)" |
||||
|
> |
||||
|
<el-icon :size="22"><component :is="tab.icon" /></el-icon> |
||||
|
<span>{{ tab.label }}</span> |
||||
|
<el-badge |
||||
|
v-if="tab.path === '/cart' && mallStore.cartBadge" |
||||
|
:value="mallStore.cartBadge" |
||||
|
class="tab-badge" |
||||
|
/> |
||||
|
</div> |
||||
|
</nav> |
||||
|
|
||||
|
<!-- AI咨询悬浮按钮 --> |
||||
|
<div v-if="showTabBar" class="ai-float-btn" @click="jumpToHealthAI({ page: 'chat' })"> |
||||
|
<el-icon :size="24" color="#fff"><ChatDotRound /></el-icon> |
||||
|
<span>AI咨询</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { computed } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { useMallStore } from '@/stores/mall' |
||||
|
import { jumpToHealthAI } from '@/utils/healthAI' |
||||
|
import { |
||||
|
ArrowLeft, Search, ShoppingCart, HomeFilled, |
||||
|
Grid, ShoppingCartFull, User, ChatDotRound |
||||
|
} from '@element-plus/icons-vue' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
const mallStore = useMallStore() |
||||
|
|
||||
|
const tabs = [ |
||||
|
{ path: '/', label: '首页', icon: HomeFilled }, |
||||
|
{ path: '/category', label: '分类', icon: Grid }, |
||||
|
{ path: '/cart', label: '购物车', icon: ShoppingCartFull }, |
||||
|
{ path: '/member', label: '我的', icon: User } |
||||
|
] |
||||
|
|
||||
|
const showTabBar = computed(() => route.meta.tabBar === true) |
||||
|
|
||||
|
const showBack = computed(() => !showTabBar.value) |
||||
|
|
||||
|
const isActiveTab = (path: string) => { |
||||
|
if (path === '/') return route.path === '/' |
||||
|
return route.path.startsWith(path) |
||||
|
} |
||||
|
|
||||
|
const pageTitle = computed(() => { |
||||
|
const titleMap: Record<string, string> = { |
||||
|
'/': '健康商城', |
||||
|
'/category': '商品分类', |
||||
|
'/cart': '购物车', |
||||
|
'/member': '我的', |
||||
|
'/search': '搜索', |
||||
|
'/checkout': '确认订单', |
||||
|
'/orders': '我的订单', |
||||
|
'/address': '收货地址' |
||||
|
} |
||||
|
// 动态路由标题 |
||||
|
if (route.path.startsWith('/product/')) return '商品详情' |
||||
|
if (route.path.startsWith('/orders/') && route.params.id) return '订单详情' |
||||
|
return titleMap[route.path] || '健康商城' |
||||
|
}) |
||||
|
|
||||
|
function goBack() { |
||||
|
if (window.history.length > 1) { |
||||
|
router.back() |
||||
|
} else { |
||||
|
router.push('/') |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
.mall-layout { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
height: 100vh; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
background: #F5F5F5; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.mall-header { |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 100; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
height: 48px; |
||||
|
padding: 0 12px; |
||||
|
background: #fff; |
||||
|
border-bottom: 1px solid #EBEDF0; |
||||
|
|
||||
|
.header-left { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
|
||||
|
.back-btn { |
||||
|
font-size: 20px; |
||||
|
cursor: pointer; |
||||
|
color: #333; |
||||
|
padding: 4px; |
||||
|
} |
||||
|
|
||||
|
.header-title { |
||||
|
font-size: 17px; |
||||
|
font-weight: 600; |
||||
|
color: #333; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.header-right { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 16px; |
||||
|
|
||||
|
.header-icon { |
||||
|
font-size: 20px; |
||||
|
cursor: pointer; |
||||
|
color: #333; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.mall-content { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
-webkit-overflow-scrolling: touch; |
||||
|
} |
||||
|
|
||||
|
.mall-tabbar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-around; |
||||
|
height: 50px; |
||||
|
background: #fff; |
||||
|
border-top: 1px solid #EBEDF0; |
||||
|
padding-bottom: env(safe-area-inset-bottom); |
||||
|
|
||||
|
.tab-item { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
flex: 1; |
||||
|
gap: 2px; |
||||
|
cursor: pointer; |
||||
|
color: #999; |
||||
|
position: relative; |
||||
|
transition: color 0.2s; |
||||
|
|
||||
|
span { |
||||
|
font-size: 11px; |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
color: #52C41A; |
||||
|
} |
||||
|
|
||||
|
.tab-badge { |
||||
|
position: absolute; |
||||
|
top: -2px; |
||||
|
right: 50%; |
||||
|
transform: translateX(18px); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.ai-float-btn { |
||||
|
position: fixed; |
||||
|
right: 16px; |
||||
|
bottom: 70px; |
||||
|
width: 56px; |
||||
|
height: 56px; |
||||
|
border-radius: 50%; |
||||
|
background: linear-gradient(135deg, #52C41A, #389E0D); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
gap: 2px; |
||||
|
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.4); |
||||
|
cursor: pointer; |
||||
|
z-index: 99; |
||||
|
transition: transform 0.2s; |
||||
|
|
||||
|
span { |
||||
|
font-size: 10px; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
&:active { |
||||
|
transform: scale(0.92); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 购物车 badge 样式调整 |
||||
|
.cart-badge { |
||||
|
:deep(.el-badge__content) { |
||||
|
font-size: 10px; |
||||
|
height: 16px; |
||||
|
line-height: 16px; |
||||
|
padding: 0 4px; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,249 @@ |
|||||
|
<template> |
||||
|
<div class="address-page"> |
||||
|
<!-- 地址列表 --> |
||||
|
<div v-loading="loading" class="page-body"> |
||||
|
<div v-if="addresses.length > 0" class="address-list"> |
||||
|
<div |
||||
|
v-for="addr in addresses" |
||||
|
:key="addr.id" |
||||
|
class="address-item" |
||||
|
:class="{ 'is-selected': isSelectMode && addr.id === selectedId }" |
||||
|
@click="handleSelectAddress(addr)" |
||||
|
> |
||||
|
<div v-if="addr.is_default" class="default-badge">默认</div> |
||||
|
<div class="addr-body"> |
||||
|
<div class="addr-top-row"> |
||||
|
<span class="addr-name">{{ addr.receiver_name }}</span> |
||||
|
<span class="addr-phone">{{ addr.phone }}</span> |
||||
|
<el-tag v-if="addr.tag" size="small" class="addr-tag" :type="tagType(addr.tag)">{{ addr.tag }}</el-tag> |
||||
|
</div> |
||||
|
<div class="addr-detail">{{ addr.province }}{{ addr.city }}{{ addr.district }}{{ addr.detail_addr }}</div> |
||||
|
</div> |
||||
|
<div class="addr-actions"> |
||||
|
<el-button v-if="!addr.is_default" text size="small" class="action-btn" @click.stop="handleSetDefault(addr.id)"> |
||||
|
<el-icon><Star /></el-icon><span>设为默认</span> |
||||
|
</el-button> |
||||
|
<el-button text size="small" class="action-btn" @click.stop="openEditDialog(addr)"> |
||||
|
<el-icon><Edit /></el-icon><span>编辑</span> |
||||
|
</el-button> |
||||
|
<el-popconfirm title="确定删除该地址吗?" confirm-button-text="删除" cancel-button-text="取消" confirm-button-type="danger" @confirm="handleDelete(addr.id)"> |
||||
|
<template #reference> |
||||
|
<el-button text size="small" class="action-btn action-danger" @click.stop><el-icon><Delete /></el-icon><span>删除</span></el-button> |
||||
|
</template> |
||||
|
</el-popconfirm> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-else-if="!loading" class="address-empty"> |
||||
|
<el-empty description="暂无收货地址" :image-size="100"> |
||||
|
<template #description><p class="empty-text">暂无收货地址,请添加一个</p></template> |
||||
|
</el-empty> |
||||
|
</div> |
||||
|
<div class="bottom-spacer" /> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 底部添加按钮 --> |
||||
|
<div class="add-bar"> |
||||
|
<el-button type="primary" class="add-btn" @click="openEditDialog(null)"> |
||||
|
<el-icon><Plus /></el-icon>新增收货地址 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 编辑弹窗 --> |
||||
|
<el-drawer v-model="dialogVisible" :title="isEditing ? '编辑地址' : '新增地址'" direction="btt" size="85%" :close-on-click-modal="false" class="address-drawer"> |
||||
|
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" class="address-form"> |
||||
|
<el-form-item label="收件人" prop="receiver_name"> |
||||
|
<el-input v-model="formData.receiver_name" placeholder="请输入收件人姓名" maxlength="20" clearable /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="手机号码" prop="phone"> |
||||
|
<el-input v-model="formData.phone" placeholder="请输入手机号码" maxlength="11" clearable /> |
||||
|
</el-form-item> |
||||
|
<div class="region-row"> |
||||
|
<el-form-item label="省份" prop="province" class="region-item"><el-input v-model="formData.province" placeholder="省" /></el-form-item> |
||||
|
<el-form-item label="城市" prop="city" class="region-item"><el-input v-model="formData.city" placeholder="市" /></el-form-item> |
||||
|
<el-form-item label="区/县" prop="district" class="region-item"><el-input v-model="formData.district" placeholder="区/县" /></el-form-item> |
||||
|
</div> |
||||
|
<el-form-item label="详细地址" prop="detail_addr"> |
||||
|
<el-input v-model="formData.detail_addr" type="textarea" placeholder="请输入街道、门牌号等详细地址" :rows="2" maxlength="100" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="邮政编码"> |
||||
|
<el-input v-model="formData.postal_code" placeholder="选填" maxlength="6" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="标签"> |
||||
|
<div class="tag-select"> |
||||
|
<span v-for="t in tagOptions" :key="t" class="tag-option" :class="{ active: formData.tag === t }" @click="formData.tag = formData.tag === t ? '' : t">{{ t }}</span> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
<el-form-item> |
||||
|
<div class="default-switch"> |
||||
|
<span class="switch-label">设为默认地址</span> |
||||
|
<el-switch v-model="formData.is_default" active-color="#52C41A" /> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
<template #footer> |
||||
|
<div class="drawer-footer"> |
||||
|
<el-button class="cancel-btn" @click="dialogVisible = false">取消</el-button> |
||||
|
<el-button type="primary" class="save-btn" :loading="saving" @click="handleSave">保存</el-button> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-drawer> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive, computed, onMounted } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import type { FormInstance, FormRules } from 'element-plus' |
||||
|
import { Plus, Star, Edit, Delete } from '@element-plus/icons-vue' |
||||
|
import { getAddressesApi, createAddressApi, updateAddressApi, deleteAddressApi, setDefaultAddressApi } from '@/api/mall' |
||||
|
import type { Address } from '@/types' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const _router = useRouter() |
||||
|
|
||||
|
const isSelectMode = computed(() => route.query.from === 'checkout') |
||||
|
const selectedId = computed(() => Number(route.query.selected_id) || 0) |
||||
|
|
||||
|
const addresses = ref<Address[]>([]) |
||||
|
const loading = ref(false) |
||||
|
const dialogVisible = ref(false) |
||||
|
const saving = ref(false) |
||||
|
const editingId = ref<number | null>(null) |
||||
|
const formRef = ref<FormInstance>() |
||||
|
|
||||
|
const isEditing = computed(() => editingId.value !== null) |
||||
|
const tagOptions = ['家', '公司', '学校', '其他'] |
||||
|
|
||||
|
const formData = reactive({ |
||||
|
receiver_name: '', phone: '', province: '', city: '', district: '', detail_addr: '', postal_code: '', tag: '', is_default: false |
||||
|
}) |
||||
|
|
||||
|
const formRules: FormRules = { |
||||
|
receiver_name: [{ required: true, message: '请输入收件人姓名', trigger: 'blur' }], |
||||
|
phone: [{ required: true, message: '请输入手机号码', trigger: 'blur' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号码', trigger: 'blur' }], |
||||
|
province: [{ required: true, message: '请输入省份', trigger: 'blur' }], |
||||
|
city: [{ required: true, message: '请输入城市', trigger: 'blur' }], |
||||
|
district: [{ required: true, message: '请输入区/县', trigger: 'blur' }], |
||||
|
detail_addr: [{ required: true, message: '请输入详细地址', trigger: 'blur' }] |
||||
|
} |
||||
|
|
||||
|
function tagType(tag: string): '' | 'success' | 'warning' | 'info' | 'danger' { |
||||
|
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = { '家': 'success', '公司': '', '学校': 'warning', '其他': 'info' } |
||||
|
return map[tag] || 'info' |
||||
|
} |
||||
|
|
||||
|
async function loadAddresses() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await getAddressesApi() as unknown as { addresses?: Address[] } | Address[] |
||||
|
addresses.value = Array.isArray(data) ? data : (data?.addresses || []) |
||||
|
} catch { /* error handled */ } |
||||
|
finally { loading.value = false } |
||||
|
} |
||||
|
|
||||
|
function openEditDialog(addr: Address | null) { |
||||
|
if (addr) { |
||||
|
editingId.value = addr.id |
||||
|
Object.assign(formData, { receiver_name: addr.receiver_name, phone: addr.phone, province: addr.province, city: addr.city, district: addr.district, detail_addr: addr.detail_addr, postal_code: addr.postal_code || '', tag: addr.tag || '', is_default: addr.is_default }) |
||||
|
} else { |
||||
|
editingId.value = null |
||||
|
Object.assign(formData, { receiver_name: '', phone: '', province: '', city: '', district: '', detail_addr: '', postal_code: '', tag: '', is_default: addresses.value.length === 0 }) |
||||
|
} |
||||
|
dialogVisible.value = true |
||||
|
} |
||||
|
|
||||
|
async function handleSave() { |
||||
|
if (!formRef.value) return |
||||
|
const valid = await formRef.value.validate().catch(() => false) |
||||
|
if (!valid) return |
||||
|
saving.value = true |
||||
|
try { |
||||
|
const payload = { receiver_name: formData.receiver_name, phone: formData.phone, province: formData.province, city: formData.city, district: formData.district, detail_addr: formData.detail_addr, postal_code: formData.postal_code || undefined, is_default: formData.is_default, tag: formData.tag || undefined } |
||||
|
if (isEditing.value) { await updateAddressApi(editingId.value!, payload); ElMessage.success('地址已更新') } |
||||
|
else { await createAddressApi(payload); ElMessage.success('地址已添加') } |
||||
|
dialogVisible.value = false |
||||
|
await loadAddresses() |
||||
|
} catch { /* error handled */ } |
||||
|
finally { saving.value = false } |
||||
|
} |
||||
|
|
||||
|
async function handleDelete(id: number) { |
||||
|
try { await deleteAddressApi(id); ElMessage.success('地址已删除'); await loadAddresses() } catch { /* error handled */ } |
||||
|
} |
||||
|
|
||||
|
async function handleSetDefault(id: number) { |
||||
|
try { await setDefaultAddressApi(id); ElMessage.success('已设为默认地址'); await loadAddresses() } catch { /* error handled */ } |
||||
|
} |
||||
|
|
||||
|
function handleSelectAddress(_addr: Address) { |
||||
|
if (isSelectMode.value) { _router.back() } |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { loadAddresses() }) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$accent: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$border: #EBEDF0; |
||||
|
|
||||
|
.address-page { min-height: 100vh; background: $bg; max-width: 750px; margin: 0 auto; } |
||||
|
.page-body { padding: 12px; } |
||||
|
|
||||
|
.address-list { display: flex; flex-direction: column; gap: 10px; } |
||||
|
|
||||
|
.address-item { |
||||
|
position: relative; background: #fff; border-radius: 12px; padding: 16px 14px; transition: box-shadow 0.2s; |
||||
|
&.is-selected { box-shadow: 0 0 0 2px $primary; } |
||||
|
.default-badge { position: absolute; top: 0; right: 0; background: $primary; color: #fff; font-size: 11px; font-weight: 600; padding: 2px 10px; border-radius: 0 12px 0 8px; letter-spacing: 1px; } |
||||
|
.addr-body { |
||||
|
.addr-top-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; |
||||
|
.addr-name { font-size: 17px; font-weight: 600; color: $text; } |
||||
|
.addr-phone { font-size: 15px; color: $text-secondary; } |
||||
|
.addr-tag { font-size: 11px; height: 20px; border-radius: 4px; } |
||||
|
} |
||||
|
.addr-detail { font-size: 14px; color: $text-secondary; line-height: 1.6; word-break: break-all; padding-right: 10px; } |
||||
|
} |
||||
|
.addr-actions { |
||||
|
display: flex; align-items: center; gap: 4px; margin-top: 12px; padding-top: 10px; border-top: 1px solid $border; |
||||
|
.action-btn { font-size: 13px; color: $text-secondary; padding: 4px 8px; gap: 3px; &:hover { color: $accent; } &.action-danger:hover { color: $price-color; } .el-icon { font-size: 14px; } } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.address-empty { display: flex; align-items: center; justify-content: center; min-height: 50vh; .empty-text { font-size: 15px; color: $text-hint; } } |
||||
|
.bottom-spacer { height: 80px; } |
||||
|
|
||||
|
.add-bar { |
||||
|
position: fixed; bottom: 0; left: 0; right: 0; max-width: 750px; margin: 0 auto; padding: 10px 14px; padding-bottom: calc(10px + env(safe-area-inset-bottom)); background: #fff; border-top: 1px solid $border; z-index: 100; |
||||
|
.add-btn { width: 100%; height: 46px; font-size: 17px; font-weight: 600; border-radius: 23px; background: $primary; border-color: $primary; .el-icon { font-size: 18px; margin-right: 4px; } } |
||||
|
} |
||||
|
|
||||
|
.address-form { |
||||
|
:deep(.el-form-item__label) { font-size: 15px; font-weight: 500; color: $text; } |
||||
|
:deep(.el-input__inner), :deep(.el-textarea__inner) { font-size: 15px; } |
||||
|
.region-row { display: flex; gap: 10px; .region-item { flex: 1; } } |
||||
|
.tag-select { |
||||
|
display: flex; gap: 10px; flex-wrap: wrap; |
||||
|
.tag-option { |
||||
|
display: inline-flex; align-items: center; justify-content: center; min-width: 56px; height: 34px; padding: 0 14px; border-radius: 17px; font-size: 14px; color: $text-secondary; background: #F5F5F5; border: 1px solid $border; cursor: pointer; transition: all 0.2s; |
||||
|
&.active { color: $primary; background: #F6FFED; border-color: $primary; font-weight: 500; } |
||||
|
&:active { transform: scale(0.96); } |
||||
|
} |
||||
|
} |
||||
|
.default-switch { display: flex; align-items: center; justify-content: space-between; width: 100%; .switch-label { font-size: 15px; color: $text; } } |
||||
|
} |
||||
|
|
||||
|
.drawer-footer { |
||||
|
display: flex; gap: 12px; |
||||
|
.cancel-btn { flex: 1; height: 44px; font-size: 16px; border-radius: 22px; } |
||||
|
.save-btn { flex: 2; height: 44px; font-size: 16px; font-weight: 600; border-radius: 22px; background: $primary; border-color: $primary; } |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,268 @@ |
|||||
|
<template> |
||||
|
<div class="cart-page"> |
||||
|
<!-- AI 咨询提示条 --> |
||||
|
<div v-if="mallStore.cartItems.length > 0" class="ai-tip-bar" @click="goAIConsult"> |
||||
|
<el-icon :size="18" color="#1890FF"><ChatDotRound /></el-icon> |
||||
|
<span class="ai-tip-text">不确定选哪个?<strong>咨询AI</strong> 为您推荐适合的健康产品</span> |
||||
|
<el-icon :size="14" color="#999"><ArrowRight /></el-icon> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 购物车列表 --> |
||||
|
<div v-if="mallStore.cartItems.length > 0" v-loading="mallStore.cartLoading" class="cart-list"> |
||||
|
<div |
||||
|
v-for="item in mallStore.cartItems" |
||||
|
:key="item.id" |
||||
|
class="cart-item" |
||||
|
> |
||||
|
<!-- 勾选框 --> |
||||
|
<el-checkbox |
||||
|
:model-value="item.selected" |
||||
|
class="item-checkbox" |
||||
|
@change="(val: boolean) => handleSelect(item.id, val)" |
||||
|
/> |
||||
|
|
||||
|
<!-- 商品图片 --> |
||||
|
<div class="item-image" @click="goProductDetail(item.product_id)"> |
||||
|
<el-image :src="item.image" fit="cover" lazy> |
||||
|
<template #error> |
||||
|
<div class="image-placeholder"> |
||||
|
<el-icon :size="24"><Picture /></el-icon> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-image> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品信息 --> |
||||
|
<div class="item-info"> |
||||
|
<div class="item-name" @click="goProductDetail(item.product_id)"> |
||||
|
{{ item.product_name }} |
||||
|
</div> |
||||
|
<div v-if="item.sku_name" class="item-sku">{{ item.sku_name }}</div> |
||||
|
<div class="item-bottom"> |
||||
|
<span class="item-price">¥{{ item.price.toFixed(2) }}</span> |
||||
|
<el-input-number |
||||
|
:model-value="item.quantity" |
||||
|
:min="1" |
||||
|
:max="item.stock" |
||||
|
size="small" |
||||
|
controls-position="right" |
||||
|
class="item-quantity" |
||||
|
@change="(val: number | undefined) => handleQuantity(item.id, val)" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 删除按钮 --> |
||||
|
<el-popconfirm |
||||
|
title="确定删除该商品吗?" |
||||
|
confirm-button-text="删除" |
||||
|
cancel-button-text="取消" |
||||
|
confirm-button-type="danger" |
||||
|
@confirm="handleDelete(item.id)" |
||||
|
> |
||||
|
<template #reference> |
||||
|
<el-icon class="item-delete"><Delete /></el-icon> |
||||
|
</template> |
||||
|
</el-popconfirm> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 空购物车 --> |
||||
|
<div v-else class="cart-empty"> |
||||
|
<el-empty description="购物车是空的" :image-size="120"> |
||||
|
<el-button type="primary" class="go-shop-btn" @click="router.push('/')"> |
||||
|
去逛逛 |
||||
|
</el-button> |
||||
|
</el-empty> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 底部结算栏 --> |
||||
|
<div v-if="mallStore.cartItems.length > 0" class="cart-footer"> |
||||
|
<div class="footer-left"> |
||||
|
<el-checkbox |
||||
|
:model-value="mallStore.isAllSelected" |
||||
|
@change="(val: boolean) => mallStore.toggleSelectAll(val)" |
||||
|
/> |
||||
|
<span class="select-all-text">全选</span> |
||||
|
</div> |
||||
|
<div class="footer-center"> |
||||
|
<span class="total-label">合计:</span> |
||||
|
<span class="total-price">¥{{ mallStore.cartTotalAmount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<el-button |
||||
|
type="primary" |
||||
|
class="checkout-btn" |
||||
|
:disabled="mallStore.cartSelectedCount === 0" |
||||
|
@click="goCheckout" |
||||
|
> |
||||
|
去结算{{ mallStore.cartSelectedCount > 0 ? `(${mallStore.cartSelectedCount})` : '' }} |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { onMounted } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { useMallStore } from '@/stores/mall' |
||||
|
import { jumpToHealthAI } from '@/utils/healthAI' |
||||
|
import type { CartItem } from '@/types' |
||||
|
import { Delete, ChatDotRound, ArrowRight, Picture } from '@element-plus/icons-vue' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const mallStore = useMallStore() |
||||
|
|
||||
|
onMounted(() => { |
||||
|
mallStore.fetchCart() |
||||
|
}) |
||||
|
|
||||
|
/** 勾选/取消勾选 */ |
||||
|
function handleSelect(id: number, selected: boolean) { |
||||
|
mallStore.updateCartItem(id, { selected }) |
||||
|
} |
||||
|
|
||||
|
/** 修改数量 */ |
||||
|
function handleQuantity(id: number, val: number | undefined) { |
||||
|
if (val != null && val >= 1) { |
||||
|
mallStore.updateCartItem(id, { quantity: val }) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 删除商品 */ |
||||
|
function handleDelete(id: number) { |
||||
|
mallStore.removeCartItem(id) |
||||
|
} |
||||
|
|
||||
|
/** 跳转商品详情 */ |
||||
|
function goProductDetail(productId: number) { |
||||
|
router.push(`/product/${productId}`) |
||||
|
} |
||||
|
|
||||
|
/** AI 咨询 */ |
||||
|
function goAIConsult() { |
||||
|
jumpToHealthAI({ page: 'chat', source: 'cart' }) |
||||
|
} |
||||
|
|
||||
|
/** 去结算 */ |
||||
|
function goCheckout() { |
||||
|
const selectedIds = mallStore.selectedItems.map((item: CartItem) => item.id) |
||||
|
if (selectedIds.length === 0) return |
||||
|
router.push({ |
||||
|
path: '/checkout', |
||||
|
query: { cart_item_ids: selectedIds.join(',') } |
||||
|
}) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$accent: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$border: #EBEDF0; |
||||
|
|
||||
|
.cart-page { |
||||
|
min-height: 100%; |
||||
|
background: $bg; |
||||
|
padding-bottom: 60px; |
||||
|
} |
||||
|
|
||||
|
.ai-tip-bar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
margin: 10px 12px; |
||||
|
padding: 10px 14px; |
||||
|
background: linear-gradient(135deg, #E6F7FF, #F0F5FF); |
||||
|
border-radius: 10px; |
||||
|
border: 1px solid #BAE7FF; |
||||
|
cursor: pointer; |
||||
|
transition: opacity 0.2s; |
||||
|
|
||||
|
&:active { opacity: 0.75; } |
||||
|
|
||||
|
.ai-tip-text { |
||||
|
flex: 1; |
||||
|
font-size: 14px; |
||||
|
color: $text-secondary; |
||||
|
line-height: 1.4; |
||||
|
strong { color: $accent; } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.cart-list { padding: 0 12px 12px; } |
||||
|
|
||||
|
.cart-item { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
gap: 10px; |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
padding: 14px 12px; |
||||
|
margin-bottom: 10px; |
||||
|
position: relative; |
||||
|
|
||||
|
.item-checkbox { |
||||
|
margin-top: 24px; |
||||
|
flex-shrink: 0; |
||||
|
:deep(.el-checkbox__inner) { width: 20px; height: 20px; border-radius: 50%; &::after { left: 6px; top: 2px; } } |
||||
|
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) { background-color: $primary; border-color: $primary; } |
||||
|
} |
||||
|
|
||||
|
.item-image { |
||||
|
width: 90px; height: 90px; border-radius: 8px; overflow: hidden; flex-shrink: 0; cursor: pointer; |
||||
|
.el-image { width: 100%; height: 100%; } |
||||
|
.image-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #F5F5F5; color: #ccc; } |
||||
|
} |
||||
|
|
||||
|
.item-info { |
||||
|
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; |
||||
|
.item-name { font-size: 15px; font-weight: 500; color: $text; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; } |
||||
|
.item-sku { font-size: 12px; color: $text-hint; background: #F7F8FA; padding: 2px 8px; border-radius: 4px; display: inline-block; align-self: flex-start; } |
||||
|
.item-bottom { |
||||
|
display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; |
||||
|
.item-price { font-size: 17px; font-weight: 600; color: $price-color; } |
||||
|
.item-quantity { width: 100px; :deep(.el-input__inner) { font-size: 14px; } } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.item-delete { |
||||
|
position: absolute; top: 12px; right: 10px; font-size: 18px; color: $text-hint; cursor: pointer; padding: 4px; transition: color 0.2s; |
||||
|
&:hover { color: $price-color; } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.cart-empty { |
||||
|
display: flex; align-items: center; justify-content: center; min-height: 60vh; |
||||
|
:deep(.el-empty__description p) { font-size: 16px; color: $text-hint; } |
||||
|
.go-shop-btn { margin-top: 12px; padding: 10px 36px; font-size: 16px; border-radius: 20px; background: $primary; border-color: $primary; } |
||||
|
} |
||||
|
|
||||
|
.cart-footer { |
||||
|
position: fixed; bottom: 50px; left: 0; right: 0; max-width: 750px; margin: 0 auto; |
||||
|
height: 56px; display: flex; align-items: center; background: #fff; border-top: 1px solid $border; |
||||
|
padding: 0 12px; padding-bottom: env(safe-area-inset-bottom); z-index: 90; |
||||
|
|
||||
|
.footer-left { |
||||
|
display: flex; align-items: center; gap: 6px; flex-shrink: 0; |
||||
|
:deep(.el-checkbox__inner) { width: 20px; height: 20px; border-radius: 50%; &::after { left: 6px; top: 2px; } } |
||||
|
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) { background-color: $primary; border-color: $primary; } |
||||
|
.select-all-text { font-size: 15px; color: $text; } |
||||
|
} |
||||
|
|
||||
|
.footer-center { |
||||
|
flex: 1; text-align: right; padding-right: 12px; |
||||
|
.total-label { font-size: 14px; color: $text-secondary; } |
||||
|
.total-price { font-size: 20px; font-weight: 700; color: $price-color; } |
||||
|
} |
||||
|
|
||||
|
.checkout-btn { |
||||
|
flex-shrink: 0; height: 40px; padding: 0 24px; font-size: 16px; font-weight: 600; border-radius: 20px; |
||||
|
background: $primary; border-color: $primary; |
||||
|
&.is-disabled { background: #C8E6C9; border-color: #C8E6C9; color: #fff; } |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,490 @@ |
|||||
|
<template> |
||||
|
<div class="category-page"> |
||||
|
<!-- 左侧分类列表 --> |
||||
|
<aside class="category-sidebar"> |
||||
|
<div |
||||
|
v-for="cat in mallStore.categories" |
||||
|
:key="cat.id" |
||||
|
class="sidebar-item" |
||||
|
:class="{ active: currentCategoryId === cat.id }" |
||||
|
@click="selectCategory(cat.id)" |
||||
|
> |
||||
|
<span class="sidebar-icon">{{ cat.icon || '📦' }}</span> |
||||
|
<span class="sidebar-name">{{ cat.name }}</span> |
||||
|
</div> |
||||
|
<div v-if="mallStore.categories.length === 0 && !mallStore.categoriesLoading" class="sidebar-empty"> |
||||
|
暂无分类 |
||||
|
</div> |
||||
|
</aside> |
||||
|
|
||||
|
<!-- 右侧商品列表 --> |
||||
|
<main class="product-list" ref="productListRef" @scroll="handleScroll"> |
||||
|
<!-- 分类标题 --> |
||||
|
<div v-if="currentCategory" class="list-header"> |
||||
|
<h3>{{ currentCategory.name }}</h3> |
||||
|
<span class="product-count" v-if="total > 0">共{{ total }}件</span> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 加载骨架 --> |
||||
|
<div v-if="loading && products.length === 0" class="loading-area"> |
||||
|
<el-skeleton :rows="3" animated /> |
||||
|
<el-skeleton :rows="3" animated style="margin-top: 16px;" /> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品列表 --> |
||||
|
<div v-else class="product-items"> |
||||
|
<div |
||||
|
v-for="product in products" |
||||
|
:key="product.id" |
||||
|
class="product-card" |
||||
|
@click="router.push(`/product/${product.id}`)" |
||||
|
> |
||||
|
<div class="card-image"> |
||||
|
<img v-if="product.main_image" :src="product.main_image" :alt="product.name" /> |
||||
|
<div v-else class="image-placeholder"> |
||||
|
<el-icon :size="32" color="#52C41A"><FirstAidKit /></el-icon> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<p class="card-name">{{ product.name }}</p> |
||||
|
<p class="card-desc" v-if="product.description">{{ product.description }}</p> |
||||
|
<div class="card-tags" v-if="product.health_tags?.length"> |
||||
|
<el-tag |
||||
|
v-for="tag in product.health_tags.slice(0, 2)" |
||||
|
:key="tag" |
||||
|
size="small" |
||||
|
type="success" |
||||
|
effect="plain" |
||||
|
round |
||||
|
> |
||||
|
{{ tag }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<div class="card-footer"> |
||||
|
<div class="card-price-area"> |
||||
|
<span class="card-price">¥{{ product.price.toFixed(2) }}</span> |
||||
|
<span v-if="product.original_price > product.price" class="card-original"> |
||||
|
¥{{ product.original_price.toFixed(2) }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<el-button |
||||
|
type="primary" |
||||
|
:icon="ShoppingCart" |
||||
|
size="small" |
||||
|
circle |
||||
|
class="add-cart-btn" |
||||
|
@click.stop="handleAddCart(product)" |
||||
|
/> |
||||
|
</div> |
||||
|
<div class="card-sales" v-if="product.sales_count"> |
||||
|
已售 {{ product.sales_count }}件 |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 空状态 --> |
||||
|
<div v-if="!loading && products.length === 0" class="empty-area"> |
||||
|
<el-empty description="该分类下暂无商品" :image-size="80" /> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 加载更多 --> |
||||
|
<div v-if="products.length > 0" class="load-more"> |
||||
|
<el-button |
||||
|
v-if="hasMore" |
||||
|
:loading="loadingMore" |
||||
|
text |
||||
|
@click="loadMore" |
||||
|
> |
||||
|
{{ loadingMore ? '加载中...' : '上拉加载更多' }} |
||||
|
</el-button> |
||||
|
<span v-else class="no-more">— 已经到底了 —</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { ShoppingCart, FirstAidKit } from '@element-plus/icons-vue' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { useMallStore } from '@/stores/mall' |
||||
|
import { getProductsApi } from '@/api/mall' |
||||
|
import type { ProductListItem } from '@/types' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
const mallStore = useMallStore() |
||||
|
|
||||
|
// 当前选中分类 |
||||
|
const currentCategoryId = ref<number>(0) |
||||
|
const page = ref(1) |
||||
|
const pageSize = 10 |
||||
|
const total = ref(0) |
||||
|
const products = ref<ProductListItem[]>([]) |
||||
|
const loading = ref(false) |
||||
|
const loadingMore = ref(false) |
||||
|
const productListRef = ref<HTMLElement | null>(null) |
||||
|
|
||||
|
const currentCategory = computed(() => |
||||
|
mallStore.categories.find(c => c.id === currentCategoryId.value) |
||||
|
) |
||||
|
|
||||
|
const hasMore = computed(() => products.value.length < total.value) |
||||
|
|
||||
|
// 选择分类 |
||||
|
function selectCategory(id: number) { |
||||
|
if (currentCategoryId.value === id) return |
||||
|
currentCategoryId.value = id |
||||
|
page.value = 1 |
||||
|
products.value = [] |
||||
|
fetchProducts() |
||||
|
// 滚动到顶部 |
||||
|
nextTick(() => { |
||||
|
productListRef.value?.scrollTo({ top: 0, behavior: 'smooth' }) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 获取商品列表 |
||||
|
async function fetchProducts() { |
||||
|
if (!currentCategoryId.value) return |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await getProductsApi({ |
||||
|
category_id: currentCategoryId.value, |
||||
|
page: page.value, |
||||
|
page_size: pageSize |
||||
|
}) as unknown as { |
||||
|
products?: ProductListItem[] |
||||
|
items?: ProductListItem[] |
||||
|
total?: number |
||||
|
} |
||||
|
const list = data.products || data.items || (Array.isArray(data) ? data as unknown as ProductListItem[] : []) |
||||
|
if (page.value === 1) { |
||||
|
products.value = list |
||||
|
} else { |
||||
|
products.value.push(...list) |
||||
|
} |
||||
|
total.value = data.total ?? list.length |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
loadingMore.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加载更多 |
||||
|
async function loadMore() { |
||||
|
if (loadingMore.value || !hasMore.value) return |
||||
|
loadingMore.value = true |
||||
|
page.value++ |
||||
|
await fetchProducts() |
||||
|
} |
||||
|
|
||||
|
// 滚动检测(上拉加载) |
||||
|
function handleScroll() { |
||||
|
const el = productListRef.value |
||||
|
if (!el || loadingMore.value || !hasMore.value) return |
||||
|
const threshold = 100 |
||||
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) { |
||||
|
loadMore() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加入购物车 |
||||
|
async function handleAddCart(product: ProductListItem) { |
||||
|
try { |
||||
|
await mallStore.addToCart(product.id) |
||||
|
ElMessage.success(`已将「${product.name}」加入购物车`) |
||||
|
} catch { |
||||
|
ElMessage.error('加入购物车失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 监听路由参数 |
||||
|
watch( |
||||
|
() => route.params.id, |
||||
|
(id) => { |
||||
|
if (id) { |
||||
|
currentCategoryId.value = Number(id) |
||||
|
page.value = 1 |
||||
|
products.value = [] |
||||
|
fetchProducts() |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true } |
||||
|
) |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
// 加载分类 |
||||
|
if (mallStore.categories.length === 0) { |
||||
|
await mallStore.fetchCategories() |
||||
|
} |
||||
|
// 如果路由没有指定分类,默认选中第一个 |
||||
|
if (!currentCategoryId.value && mallStore.categories.length > 0) { |
||||
|
currentCategoryId.value = mallStore.categories[0].id |
||||
|
fetchProducts() |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price: #FF4D4F; |
||||
|
$link: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-light: #999; |
||||
|
$sidebar-width: 100px; |
||||
|
|
||||
|
.category-page { |
||||
|
display: flex; |
||||
|
height: 100%; |
||||
|
background: $bg; |
||||
|
} |
||||
|
|
||||
|
/* 左侧分类 */ |
||||
|
.category-sidebar { |
||||
|
width: $sidebar-width; |
||||
|
flex-shrink: 0; |
||||
|
background: #fff; |
||||
|
overflow-y: auto; |
||||
|
border-right: 1px solid #F0F0F0; |
||||
|
-webkit-overflow-scrolling: touch; |
||||
|
|
||||
|
.sidebar-item { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
padding: 14px 8px; |
||||
|
cursor: pointer; |
||||
|
position: relative; |
||||
|
transition: background 0.15s; |
||||
|
|
||||
|
.sidebar-icon { |
||||
|
font-size: 20px; |
||||
|
} |
||||
|
|
||||
|
.sidebar-name { |
||||
|
font-size: 13px; |
||||
|
color: $text-secondary; |
||||
|
text-align: center; |
||||
|
line-height: 1.3; |
||||
|
word-break: break-all; |
||||
|
} |
||||
|
|
||||
|
&:active { |
||||
|
background: #F5F5F5; |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
background: $bg; |
||||
|
|
||||
|
&::before { |
||||
|
content: ''; |
||||
|
position: absolute; |
||||
|
left: 0; |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%); |
||||
|
width: 3px; |
||||
|
height: 24px; |
||||
|
background: $primary; |
||||
|
border-radius: 0 3px 3px 0; |
||||
|
} |
||||
|
|
||||
|
.sidebar-name { |
||||
|
color: $primary; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.sidebar-empty { |
||||
|
padding: 24px 12px; |
||||
|
text-align: center; |
||||
|
font-size: 13px; |
||||
|
color: $text-light; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 右侧商品列表 */ |
||||
|
.product-list { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
-webkit-overflow-scrolling: touch; |
||||
|
padding: 0 10px; |
||||
|
|
||||
|
.list-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
padding: 14px 4px 10px; |
||||
|
|
||||
|
h3 { |
||||
|
font-size: 16px; |
||||
|
font-weight: 700; |
||||
|
color: $text; |
||||
|
} |
||||
|
|
||||
|
.product-count { |
||||
|
font-size: 13px; |
||||
|
color: $text-light; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.loading-area { |
||||
|
padding: 16px; |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
} |
||||
|
|
||||
|
/* 商品卡片 */ |
||||
|
.product-items { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 10px; |
||||
|
padding-bottom: 20px; |
||||
|
} |
||||
|
|
||||
|
.product-card { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
padding: 12px; |
||||
|
cursor: pointer; |
||||
|
transition: transform 0.15s; |
||||
|
|
||||
|
&:active { |
||||
|
transform: scale(0.98); |
||||
|
} |
||||
|
|
||||
|
.card-image { |
||||
|
width: 110px; |
||||
|
height: 110px; |
||||
|
flex-shrink: 0; |
||||
|
border-radius: 10px; |
||||
|
overflow: hidden; |
||||
|
background: #FAFAFA; |
||||
|
|
||||
|
img { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
object-fit: cover; |
||||
|
} |
||||
|
|
||||
|
.image-placeholder { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: linear-gradient(135deg, #F6FFED, #D9F7BE); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
|
||||
|
.card-name { |
||||
|
font-size: 15px; |
||||
|
color: $text; |
||||
|
font-weight: 500; |
||||
|
line-height: 1.4; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.card-desc { |
||||
|
font-size: 12px; |
||||
|
color: $text-light; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
margin-bottom: 6px; |
||||
|
} |
||||
|
|
||||
|
.card-tags { |
||||
|
display: flex; |
||||
|
gap: 4px; |
||||
|
margin-bottom: 8px; |
||||
|
|
||||
|
:deep(.el-tag) { |
||||
|
font-size: 11px; |
||||
|
height: 20px; |
||||
|
padding: 0 6px; |
||||
|
border-color: #B7EB8F; |
||||
|
color: #389E0D; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.card-footer { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-top: auto; |
||||
|
} |
||||
|
|
||||
|
.card-price-area { |
||||
|
display: flex; |
||||
|
align-items: baseline; |
||||
|
gap: 6px; |
||||
|
} |
||||
|
|
||||
|
.card-price { |
||||
|
font-size: 18px; |
||||
|
font-weight: 700; |
||||
|
color: $price; |
||||
|
} |
||||
|
|
||||
|
.card-original { |
||||
|
font-size: 12px; |
||||
|
color: $text-light; |
||||
|
text-decoration: line-through; |
||||
|
} |
||||
|
|
||||
|
.add-cart-btn { |
||||
|
background: $primary; |
||||
|
border-color: $primary; |
||||
|
|
||||
|
&:hover, |
||||
|
&:focus { |
||||
|
background: darken(#52C41A, 8%); |
||||
|
border-color: darken(#52C41A, 8%); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.card-sales { |
||||
|
font-size: 12px; |
||||
|
color: $text-light; |
||||
|
margin-top: 4px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.empty-area { |
||||
|
padding: 40px 0; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.load-more { |
||||
|
text-align: center; |
||||
|
padding: 16px 0 8px; |
||||
|
|
||||
|
.no-more { |
||||
|
font-size: 13px; |
||||
|
color: $text-light; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,706 @@ |
|||||
|
<template> |
||||
|
<div class="checkout-page"> |
||||
|
<!-- 顶部导航 --> |
||||
|
<div class="page-header"> |
||||
|
<el-icon class="back-btn" @click="router.back()"><ArrowLeft /></el-icon> |
||||
|
<span class="page-title">确认订单</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-loading="pageLoading" class="page-body"> |
||||
|
<!-- 收货地址卡片 --> |
||||
|
<div class="address-card" @click="goAddressList"> |
||||
|
<template v-if="selectedAddress"> |
||||
|
<div class="addr-icon"> |
||||
|
<el-icon :size="24" color="#52C41A"><Location /></el-icon> |
||||
|
</div> |
||||
|
<div class="addr-info"> |
||||
|
<div class="addr-top"> |
||||
|
<span class="addr-name">{{ selectedAddress.receiver_name }}</span> |
||||
|
<span class="addr-phone">{{ selectedAddress.phone }}</span> |
||||
|
<el-tag v-if="selectedAddress.tag" size="small" type="info" class="addr-tag"> |
||||
|
{{ selectedAddress.tag }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<div class="addr-detail"> |
||||
|
{{ fullAddress(selectedAddress) }} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="addr-action"> |
||||
|
<span class="change-text">更换</span> |
||||
|
<el-icon :size="14"><ArrowRight /></el-icon> |
||||
|
</div> |
||||
|
</template> |
||||
|
<template v-else> |
||||
|
<div class="addr-empty"> |
||||
|
<el-icon :size="22" color="#999"><CirclePlus /></el-icon> |
||||
|
<span>添加收货地址</span> |
||||
|
</div> |
||||
|
</template> |
||||
|
<!-- 彩色分割线 --> |
||||
|
<div class="addr-divider" /> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品列表 --> |
||||
|
<div v-if="previewData" class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><ShoppingCart /></el-icon> |
||||
|
<span>商品清单({{ previewData.items.length }}件)</span> |
||||
|
</div> |
||||
|
<div class="goods-list"> |
||||
|
<div |
||||
|
v-for="item in previewData.items" |
||||
|
:key="item.id" |
||||
|
class="goods-item" |
||||
|
> |
||||
|
<div class="goods-image"> |
||||
|
<el-image :src="item.image" fit="cover" lazy> |
||||
|
<template #error> |
||||
|
<div class="image-placeholder"> |
||||
|
<el-icon :size="20"><Picture /></el-icon> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-image> |
||||
|
</div> |
||||
|
<div class="goods-info"> |
||||
|
<div class="goods-name">{{ item.product_name }}</div> |
||||
|
<div v-if="item.sku_name" class="goods-sku">{{ item.sku_name }}</div> |
||||
|
<div class="goods-bottom"> |
||||
|
<span class="goods-price">¥{{ item.price.toFixed(2) }}</span> |
||||
|
<span class="goods-qty">×{{ item.quantity }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 优惠信息 --> |
||||
|
<div v-if="previewData" class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><Discount /></el-icon> |
||||
|
<span>价格明细</span> |
||||
|
</div> |
||||
|
<div class="price-rows"> |
||||
|
<div class="price-row"> |
||||
|
<span class="price-label">商品总额</span> |
||||
|
<span class="price-value">¥{{ previewData.total_amount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div v-if="previewData.discount_amount > 0" class="price-row discount"> |
||||
|
<span class="price-label"> |
||||
|
会员折扣 |
||||
|
<el-tag v-if="memberStore.memberInfo" size="small" type="warning" class="member-tag"> |
||||
|
{{ memberStore.levelInfo.icon }} {{ memberStore.levelInfo.label }} |
||||
|
</el-tag> |
||||
|
</span> |
||||
|
<span class="price-value discount-value">-¥{{ previewData.discount_amount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div class="price-row"> |
||||
|
<span class="price-label">运费</span> |
||||
|
<span class="price-value" :class="{ 'free-shipping': previewData.shipping_fee === 0 }"> |
||||
|
{{ previewData.shipping_fee === 0 ? '免运费' : `¥${previewData.shipping_fee.toFixed(2)}` }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div v-if="previewData.max_points_use > 0" class="price-row points-row"> |
||||
|
<span class="price-label"> |
||||
|
<el-checkbox v-model="usePoints" /> |
||||
|
使用积分抵扣 |
||||
|
<span class="points-hint">({{ memberStore.memberInfo?.points ?? 0 }}积分可用,最多抵扣¥{{ previewData.points_discount.toFixed(2) }})</span> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 订单备注 --> |
||||
|
<div class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><EditPen /></el-icon> |
||||
|
<span>订单备注</span> |
||||
|
</div> |
||||
|
<el-input |
||||
|
v-model="remark" |
||||
|
type="textarea" |
||||
|
placeholder="选填:如有特殊要求可在此备注" |
||||
|
:rows="2" |
||||
|
maxlength="200" |
||||
|
show-word-limit |
||||
|
class="remark-input" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 底部占位 --> |
||||
|
<div class="bottom-spacer" /> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 底部提交栏 --> |
||||
|
<div class="submit-bar"> |
||||
|
<div class="pay-info"> |
||||
|
<span class="pay-label">实付金额</span> |
||||
|
<span class="pay-amount">¥{{ actualPayAmount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<el-button |
||||
|
type="primary" |
||||
|
class="submit-btn" |
||||
|
:loading="submitting" |
||||
|
:disabled="!selectedAddress || !previewData" |
||||
|
@click="handleSubmit" |
||||
|
> |
||||
|
提交订单 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, onMounted, watch } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { |
||||
|
ArrowLeft, ArrowRight, Location, CirclePlus, |
||||
|
ShoppingCart, Picture, Discount, EditPen |
||||
|
} from '@element-plus/icons-vue' |
||||
|
import { useOrderStore } from '@/stores/order' |
||||
|
import { useMemberStore } from '@/stores/member' |
||||
|
import { getAddressesApi } from '@/api/mall' |
||||
|
import type { Address, OrderPreview } from '@/types' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
const orderStore = useOrderStore() |
||||
|
const memberStore = useMemberStore() |
||||
|
|
||||
|
// 状态 |
||||
|
const addresses = ref<Address[]>([]) |
||||
|
const selectedAddress = ref<Address | null>(null) |
||||
|
const previewData = ref<OrderPreview | null>(null) |
||||
|
const remark = ref('') |
||||
|
const usePoints = ref(false) |
||||
|
const pageLoading = ref(false) |
||||
|
const submitting = ref(false) |
||||
|
|
||||
|
// 解析购物车项 IDs |
||||
|
const cartItemIds = computed<number[]>(() => { |
||||
|
const raw = route.query.cart_item_ids as string |
||||
|
if (!raw) return [] |
||||
|
return raw.split(',').map(Number).filter(id => !isNaN(id) && id > 0) |
||||
|
}) |
||||
|
|
||||
|
// 实际支付金额 |
||||
|
const actualPayAmount = computed(() => { |
||||
|
if (!previewData.value) return 0 |
||||
|
let amount = previewData.value.pay_amount |
||||
|
if (usePoints.value && previewData.value.points_discount > 0) { |
||||
|
amount -= previewData.value.points_discount |
||||
|
} |
||||
|
return Math.max(amount, 0) |
||||
|
}) |
||||
|
|
||||
|
// 完整地址 |
||||
|
function fullAddress(addr: Address): string { |
||||
|
return `${addr.province}${addr.city}${addr.district}${addr.detail_addr}` |
||||
|
} |
||||
|
|
||||
|
// 加载地址列表 |
||||
|
async function loadAddresses() { |
||||
|
try { |
||||
|
const data = await getAddressesApi() as unknown as Address[] |
||||
|
addresses.value = Array.isArray(data) ? data : [] |
||||
|
// 优先选默认地址 |
||||
|
const defaultAddr = addresses.value.find(a => a.is_default) |
||||
|
selectedAddress.value = defaultAddr || addresses.value[0] || null |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加载订单预览 |
||||
|
async function loadPreview() { |
||||
|
if (cartItemIds.value.length === 0) return |
||||
|
try { |
||||
|
const data = await orderStore.preview( |
||||
|
cartItemIds.value, |
||||
|
selectedAddress.value?.id |
||||
|
) |
||||
|
previewData.value = data |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 跳转地址管理 |
||||
|
function goAddressList() { |
||||
|
router.push({ |
||||
|
path: '/address', |
||||
|
query: { from: 'checkout', selected_id: selectedAddress.value?.id } |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 提交订单 |
||||
|
async function handleSubmit() { |
||||
|
if (!selectedAddress.value) { |
||||
|
ElMessage.warning('请先添加收货地址') |
||||
|
return |
||||
|
} |
||||
|
if (!previewData.value || previewData.value.items.length === 0) { |
||||
|
ElMessage.warning('订单商品信息异常,请返回重试') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
submitting.value = true |
||||
|
try { |
||||
|
const order = await orderStore.create({ |
||||
|
address_id: selectedAddress.value.id, |
||||
|
cart_item_ids: cartItemIds.value, |
||||
|
points_used: usePoints.value ? previewData.value.max_points_use : 0, |
||||
|
remark: remark.value || undefined |
||||
|
}) |
||||
|
ElMessage.success('订单创建成功') |
||||
|
router.replace(`/orders/${order.id}`) |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 地址变更时重新请求预览 |
||||
|
watch(selectedAddress, () => { |
||||
|
if (selectedAddress.value && cartItemIds.value.length > 0) { |
||||
|
loadPreview() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
if (cartItemIds.value.length === 0) { |
||||
|
ElMessage.warning('请先选择要结算的商品') |
||||
|
router.back() |
||||
|
return |
||||
|
} |
||||
|
pageLoading.value = true |
||||
|
await Promise.all([ |
||||
|
loadAddresses(), |
||||
|
memberStore.fetchMemberInfo() |
||||
|
]) |
||||
|
await loadPreview() |
||||
|
pageLoading.value = false |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$accent: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$border: #EBEDF0; |
||||
|
|
||||
|
.checkout-page { |
||||
|
min-height: 100vh; |
||||
|
background: $bg; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
/* ===== 顶部导航 ===== */ |
||||
|
.page-header { |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 100; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
height: 48px; |
||||
|
background: #fff; |
||||
|
border-bottom: 1px solid $border; |
||||
|
padding: 0 14px; |
||||
|
|
||||
|
.back-btn { |
||||
|
font-size: 22px; |
||||
|
color: $text; |
||||
|
cursor: pointer; |
||||
|
padding: 4px; |
||||
|
margin-right: 8px; |
||||
|
|
||||
|
&:active { |
||||
|
opacity: 0.6; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.page-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
color: $text; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.page-body { |
||||
|
padding: 12px 12px 0; |
||||
|
} |
||||
|
|
||||
|
/* ===== 收货地址卡片 ===== */ |
||||
|
.address-card { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
padding: 16px 14px; |
||||
|
margin-bottom: 12px; |
||||
|
cursor: pointer; |
||||
|
overflow: hidden; |
||||
|
transition: box-shadow 0.2s; |
||||
|
|
||||
|
&:active { |
||||
|
box-shadow: 0 0 0 2px rgba($primary, 0.15); |
||||
|
} |
||||
|
|
||||
|
.addr-icon { |
||||
|
flex-shrink: 0; |
||||
|
width: 40px; |
||||
|
height: 40px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: #F6FFED; |
||||
|
border-radius: 50%; |
||||
|
margin-right: 12px; |
||||
|
} |
||||
|
|
||||
|
.addr-info { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
|
||||
|
.addr-top { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
margin-bottom: 6px; |
||||
|
flex-wrap: wrap; |
||||
|
|
||||
|
.addr-name { |
||||
|
font-size: 17px; |
||||
|
font-weight: 600; |
||||
|
color: $text; |
||||
|
} |
||||
|
|
||||
|
.addr-phone { |
||||
|
font-size: 15px; |
||||
|
color: $text-secondary; |
||||
|
} |
||||
|
|
||||
|
.addr-tag { |
||||
|
font-size: 11px; |
||||
|
height: 20px; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.addr-detail { |
||||
|
font-size: 14px; |
||||
|
color: $text-secondary; |
||||
|
line-height: 1.5; |
||||
|
word-break: break-all; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.addr-action { |
||||
|
flex-shrink: 0; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 2px; |
||||
|
margin-left: 8px; |
||||
|
|
||||
|
.change-text { |
||||
|
font-size: 14px; |
||||
|
color: $accent; |
||||
|
} |
||||
|
|
||||
|
.el-icon { |
||||
|
color: $accent; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.addr-empty { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
width: 100%; |
||||
|
justify-content: center; |
||||
|
padding: 10px 0; |
||||
|
|
||||
|
span { |
||||
|
font-size: 16px; |
||||
|
color: $text-hint; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 彩色波浪分割线 |
||||
|
.addr-divider { |
||||
|
position: absolute; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
height: 3px; |
||||
|
background: repeating-linear-gradient( |
||||
|
90deg, |
||||
|
$price-color 0, |
||||
|
$price-color 20%, |
||||
|
$accent 20%, |
||||
|
$accent 40%, |
||||
|
$primary 40%, |
||||
|
$primary 60%, |
||||
|
#F59E0B 60%, |
||||
|
#F59E0B 80%, |
||||
|
#8B5CF6 80%, |
||||
|
#8B5CF6 100% |
||||
|
); |
||||
|
background-size: 30px 3px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 通用卡片 ===== */ |
||||
|
.section-card { |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
padding: 16px 14px; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 6px; |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $text; |
||||
|
margin-bottom: 14px; |
||||
|
padding-bottom: 10px; |
||||
|
border-bottom: 1px solid $border; |
||||
|
|
||||
|
.el-icon { |
||||
|
color: $primary; |
||||
|
font-size: 18px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 商品列表 ===== */ |
||||
|
.goods-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.goods-item { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
|
||||
|
.goods-image { |
||||
|
width: 80px; |
||||
|
height: 80px; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
flex-shrink: 0; |
||||
|
|
||||
|
.el-image { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.image-placeholder { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: $bg; |
||||
|
color: #ccc; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.goods-info { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: space-between; |
||||
|
|
||||
|
.goods-name { |
||||
|
font-size: 15px; |
||||
|
font-weight: 500; |
||||
|
color: $text; |
||||
|
line-height: 1.4; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.goods-sku { |
||||
|
font-size: 12px; |
||||
|
color: $text-hint; |
||||
|
background: #F7F8FA; |
||||
|
padding: 2px 8px; |
||||
|
border-radius: 4px; |
||||
|
display: inline-block; |
||||
|
align-self: flex-start; |
||||
|
margin-top: 4px; |
||||
|
} |
||||
|
|
||||
|
.goods-bottom { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-top: auto; |
||||
|
|
||||
|
.goods-price { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
.goods-qty { |
||||
|
font-size: 14px; |
||||
|
color: $text-hint; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 价格明细 ===== */ |
||||
|
.price-rows { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.price-row { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
|
||||
|
.price-label { |
||||
|
font-size: 15px; |
||||
|
color: $text-secondary; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 6px; |
||||
|
flex-wrap: wrap; |
||||
|
|
||||
|
.member-tag { |
||||
|
font-size: 11px; |
||||
|
height: 20px; |
||||
|
} |
||||
|
|
||||
|
.points-hint { |
||||
|
font-size: 12px; |
||||
|
color: $text-hint; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.price-value { |
||||
|
font-size: 15px; |
||||
|
color: $text; |
||||
|
font-weight: 500; |
||||
|
|
||||
|
&.discount-value { |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
&.free-shipping { |
||||
|
color: $primary; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.points-row { |
||||
|
padding-top: 8px; |
||||
|
border-top: 1px dashed $border; |
||||
|
|
||||
|
:deep(.el-checkbox) { |
||||
|
margin-right: 0; |
||||
|
|
||||
|
.el-checkbox__input.is-checked .el-checkbox__inner { |
||||
|
background-color: $primary; |
||||
|
border-color: $primary; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 备注 ===== */ |
||||
|
.remark-input { |
||||
|
:deep(.el-textarea__inner) { |
||||
|
font-size: 15px; |
||||
|
border-radius: 8px; |
||||
|
background: #FAFAFA; |
||||
|
border-color: $border; |
||||
|
|
||||
|
&:focus { |
||||
|
border-color: $primary; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 底部占位 ===== */ |
||||
|
.bottom-spacer { |
||||
|
height: 80px; |
||||
|
} |
||||
|
|
||||
|
/* ===== 底部提交栏 ===== */ |
||||
|
.submit-bar { |
||||
|
position: fixed; |
||||
|
bottom: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
height: 60px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
background: #fff; |
||||
|
border-top: 1px solid $border; |
||||
|
padding: 0 14px; |
||||
|
padding-bottom: env(safe-area-inset-bottom); |
||||
|
z-index: 100; |
||||
|
|
||||
|
.pay-info { |
||||
|
display: flex; |
||||
|
align-items: baseline; |
||||
|
gap: 4px; |
||||
|
|
||||
|
.pay-label { |
||||
|
font-size: 14px; |
||||
|
color: $text-secondary; |
||||
|
} |
||||
|
|
||||
|
.pay-amount { |
||||
|
font-size: 24px; |
||||
|
font-weight: 700; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.submit-btn { |
||||
|
flex-shrink: 0; |
||||
|
height: 44px; |
||||
|
padding: 0 32px; |
||||
|
font-size: 17px; |
||||
|
font-weight: 600; |
||||
|
border-radius: 22px; |
||||
|
background: $primary; |
||||
|
border-color: $primary; |
||||
|
|
||||
|
&:hover, |
||||
|
&:focus { |
||||
|
background: darken(#52C41A, 5%); |
||||
|
border-color: darken(#52C41A, 5%); |
||||
|
} |
||||
|
|
||||
|
&.is-disabled { |
||||
|
background: #C8E6C9; |
||||
|
border-color: #C8E6C9; |
||||
|
color: #fff; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,511 @@ |
|||||
|
<template> |
||||
|
<div class="mall-home"> |
||||
|
<!-- 搜索栏 --> |
||||
|
<div class="search-bar" @click="router.push('/search')"> |
||||
|
<el-icon :size="18" color="#999"><Search /></el-icon> |
||||
|
<span class="search-placeholder">搜索保健品、养生食品...</span> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Banner 轮播 --> |
||||
|
<div class="banner-section"> |
||||
|
<el-carousel height="160px" :interval="4000" indicator-position="outside"> |
||||
|
<el-carousel-item v-for="banner in banners" :key="banner.id"> |
||||
|
<div class="banner-item" :style="{ background: banner.bg }"> |
||||
|
<div class="banner-text"> |
||||
|
<h3>{{ banner.title }}</h3> |
||||
|
<p>{{ banner.subtitle }}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-carousel-item> |
||||
|
</el-carousel> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 分类导航 --> |
||||
|
<div class="category-nav"> |
||||
|
<div |
||||
|
v-for="cat in displayCategories" |
||||
|
:key="cat.id" |
||||
|
class="category-item" |
||||
|
@click="router.push(`/category/${cat.id}`)" |
||||
|
> |
||||
|
<div class="category-icon"> |
||||
|
<span>{{ cat.icon || '📦' }}</span> |
||||
|
</div> |
||||
|
<span class="category-name">{{ cat.name }}</span> |
||||
|
</div> |
||||
|
<template v-if="displayCategories.length < 8"> |
||||
|
<div |
||||
|
v-for="n in (8 - displayCategories.length)" |
||||
|
:key="'placeholder-' + n" |
||||
|
class="category-item placeholder" |
||||
|
/> |
||||
|
</template> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 体质推荐卡片 --> |
||||
|
<div class="constitution-card" @click="handleConstitutionClick"> |
||||
|
<div class="constitution-bg"> |
||||
|
<div class="constitution-content"> |
||||
|
<template v-if="constitutionStore.result"> |
||||
|
<div class="constitution-info"> |
||||
|
<span class="constitution-label">根据您的体质推荐</span> |
||||
|
<h3 class="constitution-type"> |
||||
|
{{ constitutionNames[constitutionStore.result.primaryType] }} |
||||
|
</h3> |
||||
|
<p class="constitution-desc">为您精选适合的健康好物</p> |
||||
|
</div> |
||||
|
<el-button type="primary" round size="small" class="constitution-btn"> |
||||
|
查看推荐 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
<template v-else> |
||||
|
<div class="constitution-info"> |
||||
|
<span class="constitution-label">了解您的体质</span> |
||||
|
<h3 class="constitution-type">测测你的体质</h3> |
||||
|
<p class="constitution-desc">个性化推荐适合您的健康产品</p> |
||||
|
</div> |
||||
|
<el-button type="primary" round size="small" class="constitution-btn"> |
||||
|
开始测试 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 热销商品列表 --> |
||||
|
<div class="featured-section"> |
||||
|
<div class="section-header"> |
||||
|
<h3 class="section-title">🔥 热销推荐</h3> |
||||
|
<span class="section-more" @click="router.push('/category')">更多 ›</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="loading" class="loading-wrap"> |
||||
|
<el-skeleton :rows="4" animated /> |
||||
|
</div> |
||||
|
|
||||
|
<div v-else class="product-grid"> |
||||
|
<div |
||||
|
v-for="product in featuredProducts" |
||||
|
:key="product.id" |
||||
|
class="product-card" |
||||
|
@click="router.push(`/product/${product.id}`)" |
||||
|
> |
||||
|
<div class="product-image"> |
||||
|
<img |
||||
|
v-if="product.main_image" |
||||
|
:src="product.main_image" |
||||
|
:alt="product.name" |
||||
|
/> |
||||
|
<div v-else class="image-placeholder"> |
||||
|
<el-icon :size="36" color="#52C41A"><FirstAidKit /></el-icon> |
||||
|
</div> |
||||
|
<span v-if="product.is_featured" class="product-badge">热销</span> |
||||
|
</div> |
||||
|
<div class="product-info"> |
||||
|
<p class="product-name">{{ product.name }}</p> |
||||
|
<div class="product-tags" v-if="product.health_tags?.length"> |
||||
|
<el-tag |
||||
|
v-for="tag in product.health_tags.slice(0, 2)" |
||||
|
:key="tag" |
||||
|
size="small" |
||||
|
type="success" |
||||
|
effect="plain" |
||||
|
round |
||||
|
> |
||||
|
{{ tag }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<div class="product-price-row"> |
||||
|
<span class="product-price">¥{{ product.price.toFixed(2) }}</span> |
||||
|
<span v-if="product.original_price > product.price" class="product-original-price"> |
||||
|
¥{{ product.original_price.toFixed(2) }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="product-sales" v-if="product.sales_count"> |
||||
|
已售 {{ product.sales_count }}件 |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="!loading && featuredProducts.length === 0" class="empty-state"> |
||||
|
<el-empty description="暂无商品" :image-size="80" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { Search, FirstAidKit } from '@element-plus/icons-vue' |
||||
|
import { useMallStore } from '@/stores/mall' |
||||
|
import { useConstitutionStore } from '@/stores/constitution' |
||||
|
import { getFeaturedProductsApi } from '@/api/mall' |
||||
|
import { goToConstitutionTest } from '@/utils/healthAI' |
||||
|
import { constitutionNames } from '@/types' |
||||
|
import type { ProductListItem } from '@/types' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const mallStore = useMallStore() |
||||
|
const constitutionStore = useConstitutionStore() |
||||
|
|
||||
|
// Banner 数据 |
||||
|
const banners = [ |
||||
|
{ |
||||
|
id: 1, |
||||
|
title: '春季养生专场', |
||||
|
subtitle: '应季滋补 · 限时特惠', |
||||
|
bg: 'linear-gradient(135deg, #52C41A 0%, #95DE64 100%)' |
||||
|
}, |
||||
|
{ |
||||
|
id: 2, |
||||
|
title: '体质调养精选', |
||||
|
subtitle: '科学调理 · 对症推荐', |
||||
|
bg: 'linear-gradient(135deg, #1890FF 0%, #69C0FF 100%)' |
||||
|
}, |
||||
|
{ |
||||
|
id: 3, |
||||
|
title: '会员专享福利', |
||||
|
subtitle: '积分兑好礼 · 多倍返积分', |
||||
|
bg: 'linear-gradient(135deg, #FA8C16 0%, #FFC069 100%)' |
||||
|
} |
||||
|
] |
||||
|
|
||||
|
// 分类数据 - 最多展示8个 |
||||
|
const displayCategories = ref(mallStore.categories.slice(0, 8)) |
||||
|
|
||||
|
// 热销商品 |
||||
|
const featuredProducts = ref<ProductListItem[]>([]) |
||||
|
const loading = ref(false) |
||||
|
|
||||
|
async function loadFeaturedProducts() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await getFeaturedProductsApi({ page: 1, page_size: 10 }) as unknown as { |
||||
|
products?: ProductListItem[] |
||||
|
items?: ProductListItem[] |
||||
|
} |
||||
|
featuredProducts.value = data.products || data.items || (Array.isArray(data) ? data as unknown as ProductListItem[] : []) |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function handleConstitutionClick() { |
||||
|
if (constitutionStore.result) { |
||||
|
router.push('/category') |
||||
|
} else { |
||||
|
goToConstitutionTest() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
const tasks: Promise<void>[] = [loadFeaturedProducts()] |
||||
|
if (mallStore.categories.length === 0) { |
||||
|
tasks.push(mallStore.fetchCategories().then(() => { |
||||
|
displayCategories.value = mallStore.categories.slice(0, 8) |
||||
|
})) |
||||
|
} |
||||
|
await Promise.all(tasks) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.mall-home { |
||||
|
padding-bottom: 20px; |
||||
|
background: #f5f5f5; |
||||
|
min-height: 100vh; |
||||
|
} |
||||
|
|
||||
|
/* 搜索栏 */ |
||||
|
.search-bar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
padding: 12px 16px; |
||||
|
margin: 12px 16px; |
||||
|
background: #fff; |
||||
|
border-radius: 24px; |
||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s; |
||||
|
} |
||||
|
|
||||
|
.search-bar:hover { |
||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
||||
|
} |
||||
|
|
||||
|
.search-placeholder { |
||||
|
color: #999; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
/* Banner 轮播 */ |
||||
|
.banner-section { |
||||
|
margin: 0 16px 16px; |
||||
|
border-radius: 12px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.banner-item { |
||||
|
height: 160px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
border-radius: 12px; |
||||
|
} |
||||
|
|
||||
|
.banner-text { |
||||
|
text-align: center; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.banner-text h3 { |
||||
|
font-size: 24px; |
||||
|
font-weight: 600; |
||||
|
margin: 0 0 8px; |
||||
|
} |
||||
|
|
||||
|
.banner-text p { |
||||
|
font-size: 14px; |
||||
|
margin: 0; |
||||
|
opacity: 0.9; |
||||
|
} |
||||
|
|
||||
|
/* 分类导航 */ |
||||
|
.category-nav { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(4, 1fr); |
||||
|
gap: 16px; |
||||
|
padding: 0 16px 20px; |
||||
|
background: #fff; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.category-item { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
cursor: pointer; |
||||
|
transition: transform 0.2s; |
||||
|
} |
||||
|
|
||||
|
.category-item:active { |
||||
|
transform: scale(0.95); |
||||
|
} |
||||
|
|
||||
|
.category-item.placeholder { |
||||
|
visibility: hidden; |
||||
|
} |
||||
|
|
||||
|
.category-icon { |
||||
|
width: 48px; |
||||
|
height: 48px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: linear-gradient(135deg, #52C41A 0%, #95DE64 100%); |
||||
|
border-radius: 12px; |
||||
|
font-size: 24px; |
||||
|
} |
||||
|
|
||||
|
.category-name { |
||||
|
font-size: 12px; |
||||
|
color: #333; |
||||
|
} |
||||
|
|
||||
|
/* 体质推荐卡片 */ |
||||
|
.constitution-card { |
||||
|
margin: 0 16px 16px; |
||||
|
border-radius: 12px; |
||||
|
overflow: hidden; |
||||
|
cursor: pointer; |
||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); |
||||
|
} |
||||
|
|
||||
|
.constitution-bg { |
||||
|
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%); |
||||
|
padding: 20px; |
||||
|
} |
||||
|
|
||||
|
.constitution-content { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.constitution-info { |
||||
|
flex: 1; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.constitution-label { |
||||
|
font-size: 12px; |
||||
|
opacity: 0.9; |
||||
|
display: block; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.constitution-type { |
||||
|
font-size: 20px; |
||||
|
font-weight: 600; |
||||
|
margin: 0 0 4px; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.constitution-desc { |
||||
|
font-size: 13px; |
||||
|
opacity: 0.85; |
||||
|
margin: 0; |
||||
|
} |
||||
|
|
||||
|
.constitution-btn { |
||||
|
margin-left: 16px; |
||||
|
} |
||||
|
|
||||
|
/* 热销商品列表 */ |
||||
|
.featured-section { |
||||
|
background: #fff; |
||||
|
padding: 16px; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.section-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
margin-bottom: 16px; |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
margin: 0; |
||||
|
color: #333; |
||||
|
} |
||||
|
|
||||
|
.section-more { |
||||
|
font-size: 14px; |
||||
|
color: #52C41A; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.loading-wrap { |
||||
|
padding: 20px 0; |
||||
|
} |
||||
|
|
||||
|
.product-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(2, 1fr); |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.product-card { |
||||
|
background: #fff; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s; |
||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); |
||||
|
} |
||||
|
|
||||
|
.product-card:active { |
||||
|
transform: scale(0.98); |
||||
|
} |
||||
|
|
||||
|
.product-image { |
||||
|
position: relative; |
||||
|
width: 100%; |
||||
|
padding-top: 100%; |
||||
|
background: #f5f5f5; |
||||
|
} |
||||
|
|
||||
|
.product-image img { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
object-fit: cover; |
||||
|
} |
||||
|
|
||||
|
.image-placeholder { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: #f5f5f5; |
||||
|
} |
||||
|
|
||||
|
.product-badge { |
||||
|
position: absolute; |
||||
|
top: 8px; |
||||
|
right: 8px; |
||||
|
background: #FF4D4F; |
||||
|
color: #fff; |
||||
|
font-size: 11px; |
||||
|
padding: 2px 6px; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
.product-info { |
||||
|
padding: 12px; |
||||
|
} |
||||
|
|
||||
|
.product-name { |
||||
|
font-size: 14px; |
||||
|
color: #333; |
||||
|
margin: 0 0 8px; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
line-height: 1.4; |
||||
|
min-height: 39px; |
||||
|
} |
||||
|
|
||||
|
.product-tags { |
||||
|
display: flex; |
||||
|
gap: 4px; |
||||
|
margin-bottom: 8px; |
||||
|
flex-wrap: wrap; |
||||
|
} |
||||
|
|
||||
|
.product-price-row { |
||||
|
display: flex; |
||||
|
align-items: baseline; |
||||
|
gap: 8px; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.product-price { |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
color: #FF4D4F; |
||||
|
} |
||||
|
|
||||
|
.product-original-price { |
||||
|
font-size: 12px; |
||||
|
color: #999; |
||||
|
text-decoration: line-through; |
||||
|
} |
||||
|
|
||||
|
.product-sales { |
||||
|
font-size: 12px; |
||||
|
color: #999; |
||||
|
} |
||||
|
|
||||
|
.empty-state { |
||||
|
padding: 40px 0; |
||||
|
text-align: center; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,667 @@ |
|||||
|
<template> |
||||
|
<div class="member-page"> |
||||
|
<!-- 用户信息卡片 --> |
||||
|
<div class="user-card"> |
||||
|
<div class="user-card-bg"></div> |
||||
|
<div class="user-info"> |
||||
|
<div class="avatar" :style="{ background: levelInfo.color }"> |
||||
|
{{ avatarText }} |
||||
|
</div> |
||||
|
<div class="user-detail"> |
||||
|
<div class="nickname">{{ authStore.user?.nickname || '未登录' }}</div> |
||||
|
<div class="phone">{{ maskedPhone }}</div> |
||||
|
</div> |
||||
|
<div class="level-badge" :style="{ background: levelInfo.color + '22', color: levelInfo.color, borderColor: levelInfo.color }"> |
||||
|
<span class="level-icon">{{ levelInfo.icon }}</span> |
||||
|
<span>{{ levelInfo.label }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 会员等级卡片 --> |
||||
|
<div class="section-card level-card"> |
||||
|
<div class="level-header"> |
||||
|
<span class="level-title"> |
||||
|
<span class="level-title-icon">{{ levelInfo.icon }}</span> |
||||
|
{{ levelInfo.label }} |
||||
|
</span> |
||||
|
<span class="points-tag"> |
||||
|
积分:<strong>{{ memberStore.memberInfo?.points ?? 0 }}</strong> |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 升级进度 --> |
||||
|
<div class="upgrade-progress"> |
||||
|
<div class="progress-labels"> |
||||
|
<span>当前等级</span> |
||||
|
<span v-if="memberStore.memberInfo?.next_level"> |
||||
|
距升级还需消费 ¥{{ memberStore.memberInfo.next_level_spent?.toFixed(2) }} |
||||
|
</span> |
||||
|
<span v-else>已是最高等级</span> |
||||
|
</div> |
||||
|
<el-progress |
||||
|
:percentage="memberStore.upgradeProgress" |
||||
|
:stroke-width="10" |
||||
|
:color="levelInfo.color" |
||||
|
:show-text="false" |
||||
|
/> |
||||
|
<div class="progress-level-labels"> |
||||
|
<span>{{ levelInfo.label }}</span> |
||||
|
<span v-if="memberStore.memberInfo?.next_level"> |
||||
|
{{ MEMBER_LEVEL_MAP[memberStore.memberInfo.next_level]?.label || '下一等级' }} |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 会员权益 --> |
||||
|
<div class="member-benefits"> |
||||
|
<div class="benefit-item"> |
||||
|
<div class="benefit-value">{{ formatDiscount(memberStore.memberInfo?.discount) }}</div> |
||||
|
<div class="benefit-label">折扣比例</div> |
||||
|
</div> |
||||
|
<div class="benefit-divider"></div> |
||||
|
<div class="benefit-item"> |
||||
|
<div class="benefit-value">{{ memberStore.memberInfo?.points_multiplier ?? 1 }}x</div> |
||||
|
<div class="benefit-label">积分倍率</div> |
||||
|
</div> |
||||
|
<div class="benefit-divider"></div> |
||||
|
<div class="benefit-item"> |
||||
|
<div class="benefit-value"> |
||||
|
{{ memberStore.memberInfo?.free_shipping_min ? `¥${memberStore.memberInfo.free_shipping_min}` : '无' }} |
||||
|
</div> |
||||
|
<div class="benefit-label">包邮门槛</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 我的订单 --> |
||||
|
<div class="section-card order-section"> |
||||
|
<div class="section-header"> |
||||
|
<span class="section-title">我的订单</span> |
||||
|
<span class="view-all" @click="router.push('/orders')"> |
||||
|
全部订单 <el-icon :size="14"><ArrowRight /></el-icon> |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="order-tabs"> |
||||
|
<div |
||||
|
v-for="tab in orderTabs" |
||||
|
:key="tab.status" |
||||
|
class="order-tab-item" |
||||
|
@click="router.push(`/orders?status=${tab.status}`)" |
||||
|
> |
||||
|
<div class="tab-icon">{{ tab.icon }}</div> |
||||
|
<div class="tab-label">{{ tab.label }}</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 功能菜单 --> |
||||
|
<div class="section-card menu-section"> |
||||
|
<div |
||||
|
v-for="menu in menuList" |
||||
|
:key="menu.label" |
||||
|
class="menu-item" |
||||
|
@click="menu.action" |
||||
|
> |
||||
|
<span class="menu-icon">{{ menu.icon }}</span> |
||||
|
<span class="menu-text">{{ menu.label }}</span> |
||||
|
<el-icon class="menu-arrow"><ArrowRight /></el-icon> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 积分记录弹窗 --> |
||||
|
<el-drawer |
||||
|
v-model="pointsDrawerVisible" |
||||
|
direction="btt" |
||||
|
:size="'70%'" |
||||
|
title="积分明细" |
||||
|
:z-index="2000" |
||||
|
class="points-drawer" |
||||
|
> |
||||
|
<div class="points-list" v-loading="memberStore.loading"> |
||||
|
<div v-if="memberStore.pointsRecords.length === 0 && !memberStore.loading" class="points-empty"> |
||||
|
<el-empty description="暂无积分记录" :image-size="80" /> |
||||
|
</div> |
||||
|
<div |
||||
|
v-for="record in memberStore.pointsRecords" |
||||
|
:key="record.id" |
||||
|
class="points-record" |
||||
|
> |
||||
|
<div class="record-left"> |
||||
|
<span class="record-type-icon">{{ getPointsIcon(record.type) }}</span> |
||||
|
<div class="record-info"> |
||||
|
<div class="record-desc">{{ record.remark || record.source }}</div> |
||||
|
<div class="record-time">{{ formatTime(record.created_at) }}</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="record-points" :class="record.points > 0 ? 'positive' : 'negative'"> |
||||
|
{{ record.points > 0 ? '+' : '' }}{{ record.points }} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 分页加载 --> |
||||
|
<div v-if="hasMorePoints" class="load-more" @click="loadMorePoints"> |
||||
|
<el-button link type="primary" :loading="memberStore.loading">加载更多</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-drawer> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, onMounted } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { ArrowRight } from '@element-plus/icons-vue' |
||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
import { useMemberStore } from '@/stores/member' |
||||
|
import { useMallStore } from '@/stores/mall' |
||||
|
import { MEMBER_LEVEL_MAP } from '@/types' |
||||
|
import { jumpToHealthAI, goToConstitutionResult, goToHealthAI } from '@/utils/healthAI' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const authStore = useAuthStore() |
||||
|
const memberStore = useMemberStore() |
||||
|
const mallStore = useMallStore() |
||||
|
|
||||
|
// ========== 用户信息 ========== |
||||
|
|
||||
|
const avatarText = computed(() => { |
||||
|
const name = authStore.user?.nickname || '?' |
||||
|
return name.charAt(0).toUpperCase() |
||||
|
}) |
||||
|
|
||||
|
const maskedPhone = computed(() => { |
||||
|
const phone = authStore.user?.phone || '' |
||||
|
if (phone.length >= 11) { |
||||
|
return phone.slice(0, 3) + '****' + phone.slice(7) |
||||
|
} |
||||
|
return phone || '未绑定手机号' |
||||
|
}) |
||||
|
|
||||
|
const levelInfo = computed(() => { |
||||
|
return memberStore.levelInfo |
||||
|
}) |
||||
|
|
||||
|
// ========== 订单入口 ========== |
||||
|
|
||||
|
const orderTabs = [ |
||||
|
{ icon: '💰', label: '待支付', status: 'pending' }, |
||||
|
{ icon: '📦', label: '待发货', status: 'paid' }, |
||||
|
{ icon: '🚚', label: '待收货', status: 'shipped' }, |
||||
|
{ icon: '✅', label: '已完成', status: 'completed' } |
||||
|
] |
||||
|
|
||||
|
// ========== 功能菜单 ========== |
||||
|
|
||||
|
const pointsDrawerVisible = ref(false) |
||||
|
|
||||
|
const menuList = [ |
||||
|
{ |
||||
|
icon: '📍', |
||||
|
label: '收货地址', |
||||
|
action: () => router.push('/address') |
||||
|
}, |
||||
|
{ |
||||
|
icon: '🪙', |
||||
|
label: '积分明细', |
||||
|
action: () => openPointsDrawer() |
||||
|
}, |
||||
|
{ |
||||
|
icon: '📋', |
||||
|
label: '我的体质报告', |
||||
|
action: () => goToConstitutionResult() |
||||
|
}, |
||||
|
{ |
||||
|
icon: '🤖', |
||||
|
label: '咨询AI助手', |
||||
|
action: () => jumpToHealthAI({ page: 'chat' }) |
||||
|
}, |
||||
|
{ |
||||
|
icon: '🏠', |
||||
|
label: '返回健康助手', |
||||
|
action: () => goToHealthAI() |
||||
|
} |
||||
|
] |
||||
|
|
||||
|
// ========== 积分记录 ========== |
||||
|
|
||||
|
const pointsPage = ref(1) |
||||
|
|
||||
|
const hasMorePoints = computed(() => { |
||||
|
const info = memberStore.pageInfo |
||||
|
return info && info.total > info.page * info.page_size |
||||
|
}) |
||||
|
|
||||
|
function openPointsDrawer() { |
||||
|
pointsDrawerVisible.value = true |
||||
|
pointsPage.value = 1 |
||||
|
memberStore.fetchPointsRecords({ page: 1, page_size: 10 }) |
||||
|
} |
||||
|
|
||||
|
function loadMorePoints() { |
||||
|
pointsPage.value++ |
||||
|
memberStore.fetchPointsRecords({ page: pointsPage.value, page_size: 10 }) |
||||
|
} |
||||
|
|
||||
|
function getPointsIcon(type: string): string { |
||||
|
const iconMap: Record<string, string> = { |
||||
|
earn: '➕', |
||||
|
spend: '➖', |
||||
|
order: '🛒', |
||||
|
refund: '↩️', |
||||
|
reward: '🎁', |
||||
|
sign: '📅' |
||||
|
} |
||||
|
return iconMap[type] || '🪙' |
||||
|
} |
||||
|
|
||||
|
function formatTime(dateStr: string): string { |
||||
|
if (!dateStr) return '' |
||||
|
const d = new Date(dateStr) |
||||
|
const y = d.getFullYear() |
||||
|
const m = String(d.getMonth() + 1).padStart(2, '0') |
||||
|
const day = String(d.getDate()).padStart(2, '0') |
||||
|
const h = String(d.getHours()).padStart(2, '0') |
||||
|
const min = String(d.getMinutes()).padStart(2, '0') |
||||
|
return `${y}-${m}-${day} ${h}:${min}` |
||||
|
} |
||||
|
|
||||
|
function formatDiscount(discount?: number): string { |
||||
|
if (!discount || discount >= 1) return '无折扣' |
||||
|
return (discount * 10).toFixed(1) + '折' |
||||
|
} |
||||
|
|
||||
|
// ========== 生命周期 ========== |
||||
|
|
||||
|
onMounted(() => { |
||||
|
memberStore.fetchMemberInfo() |
||||
|
mallStore.fetchCart() |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
$primary: #52C41A; |
||||
|
$price: #FF4D4F; |
||||
|
$info: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$radius: 12px; |
||||
|
|
||||
|
.member-page { |
||||
|
min-height: 100vh; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
background: $bg; |
||||
|
padding-bottom: 80px; |
||||
|
font-size: 15px; |
||||
|
color: $text; |
||||
|
} |
||||
|
|
||||
|
/* ===== 用户信息卡片 ===== */ |
||||
|
.user-card { |
||||
|
position: relative; |
||||
|
padding: 32px 20px 24px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.user-card-bg { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background: linear-gradient(135deg, $primary, #3aa50e); |
||||
|
z-index: 0; |
||||
|
} |
||||
|
|
||||
|
.user-info { |
||||
|
position: relative; |
||||
|
z-index: 1; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 14px; |
||||
|
} |
||||
|
|
||||
|
.avatar { |
||||
|
width: 60px; |
||||
|
height: 60px; |
||||
|
border-radius: 50%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
font-size: 26px; |
||||
|
font-weight: 700; |
||||
|
color: #fff; |
||||
|
border: 3px solid rgba(255, 255, 255, 0.5); |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.user-detail { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.nickname { |
||||
|
font-size: 20px; |
||||
|
font-weight: 700; |
||||
|
color: #fff; |
||||
|
line-height: 1.4; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.phone { |
||||
|
font-size: 14px; |
||||
|
color: rgba(255, 255, 255, 0.8); |
||||
|
margin-top: 2px; |
||||
|
} |
||||
|
|
||||
|
.level-badge { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
padding: 4px 12px; |
||||
|
border-radius: 20px; |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
border: 1px solid; |
||||
|
background: rgba(255, 255, 255, 0.9) !important; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.level-icon { |
||||
|
font-size: 15px; |
||||
|
} |
||||
|
|
||||
|
/* ===== 通用卡片 ===== */ |
||||
|
.section-card { |
||||
|
margin: 12px; |
||||
|
background: #fff; |
||||
|
border-radius: $radius; |
||||
|
padding: 18px 16px; |
||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); |
||||
|
} |
||||
|
|
||||
|
/* ===== 会员等级卡片 ===== */ |
||||
|
.level-card { |
||||
|
margin-top: -8px; |
||||
|
position: relative; |
||||
|
z-index: 2; |
||||
|
} |
||||
|
|
||||
|
.level-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 16px; |
||||
|
} |
||||
|
|
||||
|
.level-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: 700; |
||||
|
color: $text; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 6px; |
||||
|
} |
||||
|
|
||||
|
.level-title-icon { |
||||
|
font-size: 22px; |
||||
|
} |
||||
|
|
||||
|
.points-tag { |
||||
|
font-size: 14px; |
||||
|
color: $text-secondary; |
||||
|
|
||||
|
strong { |
||||
|
color: $price; |
||||
|
font-size: 18px; |
||||
|
margin-left: 2px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.upgrade-progress { |
||||
|
margin-bottom: 18px; |
||||
|
} |
||||
|
|
||||
|
.progress-labels { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
font-size: 13px; |
||||
|
color: $text-hint; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.progress-level-labels { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
font-size: 12px; |
||||
|
color: $text-hint; |
||||
|
margin-top: 4px; |
||||
|
} |
||||
|
|
||||
|
.member-benefits { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-around; |
||||
|
background: #FAFAFA; |
||||
|
border-radius: 8px; |
||||
|
padding: 14px 8px; |
||||
|
} |
||||
|
|
||||
|
.benefit-item { |
||||
|
text-align: center; |
||||
|
flex: 1; |
||||
|
} |
||||
|
|
||||
|
.benefit-value { |
||||
|
font-size: 17px; |
||||
|
font-weight: 700; |
||||
|
color: $primary; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.benefit-label { |
||||
|
font-size: 13px; |
||||
|
color: $text-hint; |
||||
|
} |
||||
|
|
||||
|
.benefit-divider { |
||||
|
width: 1px; |
||||
|
height: 30px; |
||||
|
background: #E8E8E8; |
||||
|
} |
||||
|
|
||||
|
/* ===== 订单区域 ===== */ |
||||
|
.section-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 16px; |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
font-size: 17px; |
||||
|
font-weight: 700; |
||||
|
color: $text; |
||||
|
} |
||||
|
|
||||
|
.view-all { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 2px; |
||||
|
font-size: 14px; |
||||
|
color: $text-hint; |
||||
|
cursor: pointer; |
||||
|
transition: color 0.2s; |
||||
|
|
||||
|
&:active { |
||||
|
color: $primary; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.order-tabs { |
||||
|
display: flex; |
||||
|
justify-content: space-around; |
||||
|
} |
||||
|
|
||||
|
.order-tab-item { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
cursor: pointer; |
||||
|
padding: 8px 16px; |
||||
|
border-radius: 8px; |
||||
|
transition: background 0.2s; |
||||
|
|
||||
|
&:active { |
||||
|
background: #F0F0F0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.tab-icon { |
||||
|
font-size: 28px; |
||||
|
line-height: 1; |
||||
|
} |
||||
|
|
||||
|
.tab-label { |
||||
|
font-size: 14px; |
||||
|
color: $text-secondary; |
||||
|
} |
||||
|
|
||||
|
/* ===== 功能菜单 ===== */ |
||||
|
.menu-section { |
||||
|
padding: 6px 16px; |
||||
|
} |
||||
|
|
||||
|
.menu-item { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
padding: 16px 0; |
||||
|
cursor: pointer; |
||||
|
border-bottom: 1px solid #F2F2F2; |
||||
|
transition: background 0.15s; |
||||
|
|
||||
|
&:last-child { |
||||
|
border-bottom: none; |
||||
|
} |
||||
|
|
||||
|
&:active { |
||||
|
background: #FAFAFA; |
||||
|
margin: 0 -16px; |
||||
|
padding: 16px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.menu-icon { |
||||
|
font-size: 22px; |
||||
|
margin-right: 12px; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.menu-text { |
||||
|
flex: 1; |
||||
|
font-size: 16px; |
||||
|
color: $text; |
||||
|
} |
||||
|
|
||||
|
.menu-arrow { |
||||
|
color: #CCC; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
|
||||
|
/* ===== 积分记录弹窗 ===== */ |
||||
|
.points-list { |
||||
|
padding: 0 4px; |
||||
|
} |
||||
|
|
||||
|
.points-empty { |
||||
|
padding: 40px 0; |
||||
|
} |
||||
|
|
||||
|
.points-record { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
padding: 14px 0; |
||||
|
border-bottom: 1px solid #F5F5F5; |
||||
|
|
||||
|
&:last-child { |
||||
|
border-bottom: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.record-left { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.record-type-icon { |
||||
|
font-size: 24px; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.record-info { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.record-desc { |
||||
|
font-size: 15px; |
||||
|
color: $text; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.record-time { |
||||
|
font-size: 13px; |
||||
|
color: $text-hint; |
||||
|
margin-top: 4px; |
||||
|
} |
||||
|
|
||||
|
.record-points { |
||||
|
font-size: 17px; |
||||
|
font-weight: 700; |
||||
|
flex-shrink: 0; |
||||
|
margin-left: 12px; |
||||
|
|
||||
|
&.positive { |
||||
|
color: $primary; |
||||
|
} |
||||
|
|
||||
|
&.negative { |
||||
|
color: $price; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.load-more { |
||||
|
text-align: center; |
||||
|
padding: 16px 0; |
||||
|
} |
||||
|
|
||||
|
/* ===== Drawer 样式覆盖 ===== */ |
||||
|
:deep(.el-drawer__header) { |
||||
|
font-size: 17px; |
||||
|
font-weight: 700; |
||||
|
color: $text; |
||||
|
margin-bottom: 0; |
||||
|
padding: 16px 20px; |
||||
|
border-bottom: 1px solid #F0F0F0; |
||||
|
} |
||||
|
|
||||
|
:deep(.el-drawer__body) { |
||||
|
padding: 0 16px; |
||||
|
} |
||||
|
|
||||
|
:deep(.el-progress-bar__inner) { |
||||
|
transition: width 0.6s ease; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,730 @@ |
|||||
|
<template> |
||||
|
<div class="order-detail-page"> |
||||
|
<!-- 顶部导航 --> |
||||
|
<div class="page-header"> |
||||
|
<el-icon class="back-btn" @click="router.back()"><ArrowLeft /></el-icon> |
||||
|
<span class="page-title">订单详情</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-loading="orderStore.loading" class="page-body"> |
||||
|
<template v-if="order"> |
||||
|
<!-- 订单状态卡片 --> |
||||
|
<div class="status-card"> |
||||
|
<div class="status-icon"> |
||||
|
<el-icon v-if="order.status === 'completed'" :size="48" color="#10B981"> |
||||
|
<CircleCheckFilled /> |
||||
|
</el-icon> |
||||
|
<el-icon v-else-if="order.status === 'cancelled'" :size="48" color="#9CA3AF"> |
||||
|
<CircleCloseFilled /> |
||||
|
</el-icon> |
||||
|
<el-icon v-else-if="order.status === 'refunding'" :size="48" color="#EF4444"> |
||||
|
<Warning /> |
||||
|
</el-icon> |
||||
|
<el-icon v-else :size="48" color="#1890FF"> |
||||
|
<Clock /> |
||||
|
</el-icon> |
||||
|
</div> |
||||
|
<div class="status-text"> |
||||
|
<h3>{{ ORDER_STATUS_MAP[order.status]?.label || order.status }}</h3> |
||||
|
<p v-if="order.status === 'pending'">请尽快完成支付</p> |
||||
|
<p v-else-if="order.status === 'paid'">商家正在准备发货</p> |
||||
|
<p v-else-if="order.status === 'shipped'">商品正在配送中</p> |
||||
|
<p v-else-if="order.status === 'completed'">订单已完成</p> |
||||
|
<p v-else-if="order.status === 'cancelled'">订单已取消</p> |
||||
|
<p v-else-if="order.status === 'refunding'">退款处理中</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 收货信息 --> |
||||
|
<div class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><Location /></el-icon> |
||||
|
<span>收货信息</span> |
||||
|
</div> |
||||
|
<div class="address-info"> |
||||
|
<div class="addr-row"> |
||||
|
<span class="addr-label">收货人:</span> |
||||
|
<span class="addr-value">{{ order.receiver_name }}</span> |
||||
|
</div> |
||||
|
<div class="addr-row"> |
||||
|
<span class="addr-label">联系电话:</span> |
||||
|
<span class="addr-value">{{ order.receiver_phone }}</span> |
||||
|
</div> |
||||
|
<div class="addr-row"> |
||||
|
<span class="addr-label">收货地址:</span> |
||||
|
<span class="addr-value">{{ order.receiver_addr }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 物流信息 --> |
||||
|
<div v-if="order.status === 'shipped' || order.status === 'completed'" class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><Van /></el-icon> |
||||
|
<span>物流信息</span> |
||||
|
</div> |
||||
|
<div class="shipping-info"> |
||||
|
<div class="shipping-row"> |
||||
|
<span class="shipping-label">物流公司:</span> |
||||
|
<span class="shipping-value">{{ order.shipping_company || '暂无' }}</span> |
||||
|
</div> |
||||
|
<div class="shipping-row"> |
||||
|
<span class="shipping-label">运单号:</span> |
||||
|
<span class="shipping-value">{{ order.tracking_no || '暂无' }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.ship_time" class="shipping-row"> |
||||
|
<span class="shipping-label">发货时间:</span> |
||||
|
<span class="shipping-value">{{ formatTime(order.ship_time) }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品列表 --> |
||||
|
<div class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><ShoppingBag /></el-icon> |
||||
|
<span>商品清单({{ order.items.length }}件)</span> |
||||
|
</div> |
||||
|
<div class="goods-list"> |
||||
|
<div |
||||
|
v-for="item in order.items" |
||||
|
:key="item.id" |
||||
|
class="goods-item" |
||||
|
@click="goProduct(item.product_id)" |
||||
|
> |
||||
|
<div class="goods-image"> |
||||
|
<el-image :src="item.image" fit="cover" lazy> |
||||
|
<template #error> |
||||
|
<div class="image-placeholder"> |
||||
|
<el-icon :size="20"><Picture /></el-icon> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-image> |
||||
|
</div> |
||||
|
<div class="goods-info"> |
||||
|
<div class="goods-name">{{ item.product_name }}</div> |
||||
|
<div v-if="item.sku_name" class="goods-sku">{{ item.sku_name }}</div> |
||||
|
<div class="goods-bottom"> |
||||
|
<span class="goods-price">¥{{ item.price.toFixed(2) }}</span> |
||||
|
<span class="goods-qty">×{{ item.quantity }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 订单信息 --> |
||||
|
<div class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><Document /></el-icon> |
||||
|
<span>订单信息</span> |
||||
|
</div> |
||||
|
<div class="order-info-list"> |
||||
|
<div class="info-row"> |
||||
|
<span class="info-label">订单号:</span> |
||||
|
<span class="info-value">{{ order.order_no }}</span> |
||||
|
<el-icon |
||||
|
class="copy-icon" |
||||
|
@click="copyOrderNo(order.order_no)" |
||||
|
> |
||||
|
<DocumentCopy /> |
||||
|
</el-icon> |
||||
|
</div> |
||||
|
<div class="info-row"> |
||||
|
<span class="info-label">下单时间:</span> |
||||
|
<span class="info-value">{{ formatTime(order.created_at) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.pay_time" class="info-row"> |
||||
|
<span class="info-label">支付时间:</span> |
||||
|
<span class="info-value">{{ formatTime(order.pay_time) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.receive_time" class="info-row"> |
||||
|
<span class="info-label">收货时间:</span> |
||||
|
<span class="info-value">{{ formatTime(order.receive_time) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.pay_method" class="info-row"> |
||||
|
<span class="info-label">支付方式:</span> |
||||
|
<span class="info-value">{{ getPayMethodName(order.pay_method) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.remark" class="info-row"> |
||||
|
<span class="info-label">订单备注:</span> |
||||
|
<span class="info-value">{{ order.remark }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.cancel_reason" class="info-row"> |
||||
|
<span class="info-label">取消原因:</span> |
||||
|
<span class="info-value cancel-reason">{{ order.cancel_reason }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 价格明细 --> |
||||
|
<div class="section-card"> |
||||
|
<div class="section-title"> |
||||
|
<el-icon><Wallet /></el-icon> |
||||
|
<span>价格明细</span> |
||||
|
</div> |
||||
|
<div class="price-list"> |
||||
|
<div class="price-row"> |
||||
|
<span class="price-label">商品总额</span> |
||||
|
<span class="price-value">¥{{ order.total_amount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.discount_amount > 0" class="price-row discount"> |
||||
|
<span class="price-label">优惠金额</span> |
||||
|
<span class="price-value discount-value">-¥{{ order.discount_amount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.points_used > 0" class="price-row"> |
||||
|
<span class="price-label">积分抵扣</span> |
||||
|
<span class="price-value">-¥{{ (order.points_used / 100).toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div class="price-row"> |
||||
|
<span class="price-label">运费</span> |
||||
|
<span class="price-value" :class="{ 'free-shipping': order.shipping_fee === 0 }"> |
||||
|
{{ order.shipping_fee === 0 ? '免运费' : `¥${order.shipping_fee.toFixed(2)}` }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="price-row total"> |
||||
|
<span class="price-label">实付金额</span> |
||||
|
<span class="price-value total-value">¥{{ order.pay_amount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div v-if="order.points_earned > 0" class="price-row points"> |
||||
|
<span class="price-label">获得积分</span> |
||||
|
<span class="price-value points-value">+{{ order.points_earned }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<!-- 底部占位 --> |
||||
|
<div class="bottom-spacer" /> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 底部操作栏 --> |
||||
|
<div v-if="order" class="action-bar"> |
||||
|
<el-button |
||||
|
v-if="order.status === 'completed' || order.status === 'cancelled'" |
||||
|
@click="handleRebuy" |
||||
|
> |
||||
|
再次购买 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="order.status === 'pending'" |
||||
|
type="primary" |
||||
|
@click="handlePay" |
||||
|
> |
||||
|
去支付 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="order.status === 'shipped'" |
||||
|
type="primary" |
||||
|
@click="handleConfirmReceive" |
||||
|
> |
||||
|
确认收货 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="order.status === 'pending'" |
||||
|
@click="handleCancel" |
||||
|
> |
||||
|
取消订单 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { computed, onMounted } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { |
||||
|
ArrowLeft, |
||||
|
Location, |
||||
|
Document, |
||||
|
DocumentCopy, |
||||
|
Picture, |
||||
|
Clock, |
||||
|
CreditCard, |
||||
|
Van, |
||||
|
CircleCheckFilled, |
||||
|
CircleCloseFilled, |
||||
|
Warning |
||||
|
} from '@element-plus/icons-vue' |
||||
|
import { useOrderStore } from '@/stores/order' |
||||
|
import { ORDER_STATUS_MAP } from '@/types' |
||||
|
import type { Order } from '@/types' |
||||
|
|
||||
|
const ShoppingBag = Document |
||||
|
const Wallet = CreditCard |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
const orderStore = useOrderStore() |
||||
|
|
||||
|
// 订单ID |
||||
|
const orderId = computed(() => Number(route.params.id)) |
||||
|
|
||||
|
// 当前订单 |
||||
|
const order = computed(() => orderStore.currentOrder) |
||||
|
|
||||
|
// 格式化时间 |
||||
|
function formatTime(time: string): string { |
||||
|
const date = new Date(time) |
||||
|
return date.toLocaleString('zh-CN', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit' |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 获取支付方式名称 |
||||
|
function getPayMethodName(method: string): string { |
||||
|
const methodMap: Record<string, string> = { |
||||
|
alipay: '支付宝', |
||||
|
wechat: '微信支付', |
||||
|
balance: '余额支付', |
||||
|
points: '积分支付' |
||||
|
} |
||||
|
return methodMap[method] || method |
||||
|
} |
||||
|
|
||||
|
// 复制订单号 |
||||
|
function copyOrderNo(orderNo: string) { |
||||
|
navigator.clipboard.writeText(orderNo).then(() => { |
||||
|
ElMessage.success('订单号已复制') |
||||
|
}).catch(() => { |
||||
|
ElMessage.error('复制失败') |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 跳转商品详情 |
||||
|
function goProduct(productId: number) { |
||||
|
router.push(`/product/${productId}`) |
||||
|
} |
||||
|
|
||||
|
// 再次购买 |
||||
|
function handleRebuy() { |
||||
|
if (!order.value || order.value.items.length === 0) { |
||||
|
ElMessage.warning('订单商品信息异常') |
||||
|
return |
||||
|
} |
||||
|
router.push(`/product/${order.value.items[0].product_id}`) |
||||
|
} |
||||
|
|
||||
|
// 去支付 |
||||
|
function handlePay() { |
||||
|
ElMessage.info('跳转支付页面') |
||||
|
// TODO: 实现支付逻辑 |
||||
|
} |
||||
|
|
||||
|
// 确认收货 |
||||
|
async function handleConfirmReceive() { |
||||
|
if (!order.value) return |
||||
|
try { |
||||
|
await orderStore.confirmReceive(order.value.id) |
||||
|
ElMessage.success('确认收货成功') |
||||
|
await orderStore.fetchOrder(orderId.value) |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 取消订单 |
||||
|
async function handleCancel() { |
||||
|
if (!order.value) return |
||||
|
try { |
||||
|
await orderStore.cancel(order.value.id) |
||||
|
ElMessage.success('订单已取消') |
||||
|
await orderStore.fetchOrder(orderId.value) |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
if (orderId.value) { |
||||
|
await orderStore.fetchOrder(orderId.value) |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$accent: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$border: #EBEDF0; |
||||
|
|
||||
|
.order-detail-page { |
||||
|
min-height: 100vh; |
||||
|
background: $bg; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
/* ===== 顶部导航 ===== */ |
||||
|
.page-header { |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 100; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
height: 48px; |
||||
|
background: #fff; |
||||
|
border-bottom: 1px solid $border; |
||||
|
padding: 0 14px; |
||||
|
|
||||
|
.back-btn { |
||||
|
font-size: 22px; |
||||
|
color: $text; |
||||
|
cursor: pointer; |
||||
|
padding: 4px; |
||||
|
margin-right: 8px; |
||||
|
|
||||
|
&:active { |
||||
|
opacity: 0.6; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.page-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
color: $text; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.page-body { |
||||
|
padding: 12px 12px 0; |
||||
|
} |
||||
|
|
||||
|
/* ===== 订单状态卡片 ===== */ |
||||
|
.status-card { |
||||
|
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%); |
||||
|
border-radius: 12px; |
||||
|
padding: 24px; |
||||
|
margin-bottom: 12px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
color: #fff; |
||||
|
|
||||
|
.status-icon { |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.status-text { |
||||
|
text-align: center; |
||||
|
|
||||
|
h3 { |
||||
|
font-size: 20px; |
||||
|
font-weight: 600; |
||||
|
margin: 0 0 8px; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
p { |
||||
|
font-size: 14px; |
||||
|
margin: 0; |
||||
|
opacity: 0.9; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 通用卡片 ===== */ |
||||
|
.section-card { |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
padding: 16px 14px; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 6px; |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $text; |
||||
|
margin-bottom: 14px; |
||||
|
padding-bottom: 10px; |
||||
|
border-bottom: 1px solid $border; |
||||
|
|
||||
|
.el-icon { |
||||
|
color: $primary; |
||||
|
font-size: 18px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 收货信息 ===== */ |
||||
|
.address-info { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 10px; |
||||
|
|
||||
|
.addr-row { |
||||
|
display: flex; |
||||
|
font-size: 15px; |
||||
|
|
||||
|
.addr-label { |
||||
|
color: $text-secondary; |
||||
|
min-width: 80px; |
||||
|
} |
||||
|
|
||||
|
.addr-value { |
||||
|
color: $text; |
||||
|
flex: 1; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 物流信息 ===== */ |
||||
|
.shipping-info { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 10px; |
||||
|
|
||||
|
.shipping-row { |
||||
|
display: flex; |
||||
|
font-size: 15px; |
||||
|
|
||||
|
.shipping-label { |
||||
|
color: $text-secondary; |
||||
|
min-width: 80px; |
||||
|
} |
||||
|
|
||||
|
.shipping-value { |
||||
|
color: $text; |
||||
|
flex: 1; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 商品列表 ===== */ |
||||
|
.goods-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.goods-item { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
cursor: pointer; |
||||
|
padding: 8px; |
||||
|
border-radius: 8px; |
||||
|
transition: background 0.2s; |
||||
|
|
||||
|
&:active { |
||||
|
background: $bg; |
||||
|
} |
||||
|
|
||||
|
.goods-image { |
||||
|
width: 80px; |
||||
|
height: 80px; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
flex-shrink: 0; |
||||
|
background: $bg; |
||||
|
|
||||
|
.el-image { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.image-placeholder { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: $bg; |
||||
|
color: #ccc; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.goods-info { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: space-between; |
||||
|
|
||||
|
.goods-name { |
||||
|
font-size: 15px; |
||||
|
font-weight: 500; |
||||
|
color: $text; |
||||
|
line-height: 1.4; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.goods-sku { |
||||
|
font-size: 12px; |
||||
|
color: $text-hint; |
||||
|
background: #F7F8FA; |
||||
|
padding: 2px 8px; |
||||
|
border-radius: 4px; |
||||
|
display: inline-block; |
||||
|
align-self: flex-start; |
||||
|
margin-top: 4px; |
||||
|
} |
||||
|
|
||||
|
.goods-bottom { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-top: auto; |
||||
|
|
||||
|
.goods-price { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
.goods-qty { |
||||
|
font-size: 14px; |
||||
|
color: $text-hint; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 订单信息 ===== */ |
||||
|
.order-info-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 10px; |
||||
|
|
||||
|
.info-row { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
font-size: 15px; |
||||
|
|
||||
|
.info-label { |
||||
|
color: $text-secondary; |
||||
|
min-width: 80px; |
||||
|
} |
||||
|
|
||||
|
.info-value { |
||||
|
color: $text; |
||||
|
flex: 1; |
||||
|
|
||||
|
&.cancel-reason { |
||||
|
color: $price-color; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.copy-icon { |
||||
|
margin-left: 8px; |
||||
|
color: $accent; |
||||
|
cursor: pointer; |
||||
|
font-size: 16px; |
||||
|
|
||||
|
&:active { |
||||
|
opacity: 0.6; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 价格明细 ===== */ |
||||
|
.price-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.price-row { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
|
||||
|
.price-label { |
||||
|
font-size: 15px; |
||||
|
color: $text-secondary; |
||||
|
} |
||||
|
|
||||
|
.price-value { |
||||
|
font-size: 15px; |
||||
|
color: $text; |
||||
|
font-weight: 500; |
||||
|
|
||||
|
&.discount-value { |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
&.free-shipping { |
||||
|
color: $primary; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
&.total-value { |
||||
|
font-size: 18px; |
||||
|
font-weight: 700; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
&.points-value { |
||||
|
color: $primary; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&.total { |
||||
|
padding-top: 12px; |
||||
|
border-top: 1px solid $border; |
||||
|
} |
||||
|
|
||||
|
&.points { |
||||
|
padding-top: 8px; |
||||
|
border-top: 1px dashed $border; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 底部占位 ===== */ |
||||
|
.bottom-spacer { |
||||
|
height: 80px; |
||||
|
} |
||||
|
|
||||
|
/* ===== 底部操作栏 ===== */ |
||||
|
.action-bar { |
||||
|
position: fixed; |
||||
|
bottom: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
height: 60px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: flex-end; |
||||
|
gap: 12px; |
||||
|
background: #fff; |
||||
|
border-top: 1px solid $border; |
||||
|
padding: 0 14px; |
||||
|
padding-bottom: env(safe-area-inset-bottom); |
||||
|
z-index: 100; |
||||
|
|
||||
|
.el-button { |
||||
|
height: 40px; |
||||
|
padding: 0 20px; |
||||
|
font-size: 15px; |
||||
|
border-radius: 20px; |
||||
|
|
||||
|
&.el-button--primary { |
||||
|
background: $primary; |
||||
|
border-color: $primary; |
||||
|
|
||||
|
&:hover, |
||||
|
&:focus { |
||||
|
background: darken($primary, 5%); |
||||
|
border-color: darken($primary, 5%); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,440 @@ |
|||||
|
<template> |
||||
|
<div class="order-list-page"> |
||||
|
<!-- 顶部导航 --> |
||||
|
<div class="page-header"> |
||||
|
<span class="page-title">我的订单</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-loading="orderStore.loading" class="page-body"> |
||||
|
<!-- 订单列表 --> |
||||
|
<div v-if="orderStore.orders.length > 0" class="order-list"> |
||||
|
<div |
||||
|
v-for="order in orderStore.orders" |
||||
|
:key="order.id" |
||||
|
class="order-card" |
||||
|
@click="goOrderDetail(order.id)" |
||||
|
> |
||||
|
<!-- 订单头部 --> |
||||
|
<div class="order-header"> |
||||
|
<div class="order-info"> |
||||
|
<span class="order-no">订单号:{{ order.order_no }}</span> |
||||
|
<el-tag |
||||
|
:type="getStatusType(order.status)" |
||||
|
size="small" |
||||
|
class="status-tag" |
||||
|
> |
||||
|
{{ ORDER_STATUS_MAP[order.status]?.label || order.status }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<span class="order-time">{{ formatTime(order.created_at) }}</span> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品列表 --> |
||||
|
<div class="order-items"> |
||||
|
<div |
||||
|
v-for="(item, index) in order.items.slice(0, 3)" |
||||
|
:key="item.id" |
||||
|
class="order-item" |
||||
|
> |
||||
|
<div class="item-image"> |
||||
|
<el-image :src="item.image" fit="cover" lazy> |
||||
|
<template #error> |
||||
|
<div class="image-placeholder"> |
||||
|
<el-icon :size="20"><Picture /></el-icon> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-image> |
||||
|
</div> |
||||
|
<div class="item-info"> |
||||
|
<div class="item-name">{{ item.product_name }}</div> |
||||
|
<div v-if="item.sku_name" class="item-sku">{{ item.sku_name }}</div> |
||||
|
<div class="item-bottom"> |
||||
|
<span class="item-price">¥{{ item.price.toFixed(2) }}</span> |
||||
|
<span class="item-qty">×{{ item.quantity }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div v-if="order.items.length > 3" class="more-items"> |
||||
|
还有 {{ order.items.length - 3 }} 件商品... |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 订单底部 --> |
||||
|
<div class="order-footer"> |
||||
|
<div class="order-total"> |
||||
|
<span>共 {{ order.items.length }} 件商品</span> |
||||
|
<span class="total-amount">实付:¥{{ order.pay_amount.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div class="order-actions"> |
||||
|
<el-button |
||||
|
v-if="order.status === 'completed' || order.status === 'cancelled'" |
||||
|
size="small" |
||||
|
@click.stop="handleRebuy(order)" |
||||
|
> |
||||
|
再次购买 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="order.status === 'pending'" |
||||
|
type="primary" |
||||
|
size="small" |
||||
|
@click.stop="goOrderDetail(order.id)" |
||||
|
> |
||||
|
去支付 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="order.status === 'shipped'" |
||||
|
type="primary" |
||||
|
size="small" |
||||
|
@click.stop="handleConfirmReceive(order.id)" |
||||
|
> |
||||
|
确认收货 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 空状态 --> |
||||
|
<div v-else class="empty-state"> |
||||
|
<el-empty description="暂无订单" :image-size="120"> |
||||
|
<el-button type="primary" @click="router.push('/')">去逛逛</el-button> |
||||
|
</el-empty> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 加载更多 --> |
||||
|
<div v-if="hasMore" class="load-more"> |
||||
|
<el-button |
||||
|
:loading="orderStore.loading" |
||||
|
text |
||||
|
@click="loadMore" |
||||
|
> |
||||
|
加载更多 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, onMounted } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { Picture } from '@element-plus/icons-vue' |
||||
|
import { useOrderStore } from '@/stores/order' |
||||
|
import { ORDER_STATUS_MAP } from '@/types' |
||||
|
import type { Order } from '@/types' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const orderStore = useOrderStore() |
||||
|
|
||||
|
// 当前页码 |
||||
|
const currentPage = ref(1) |
||||
|
const pageSize = 10 |
||||
|
|
||||
|
// 是否还有更多 |
||||
|
const hasMore = computed(() => { |
||||
|
return orderStore.pageInfo.total > orderStore.orders.length |
||||
|
}) |
||||
|
|
||||
|
// 获取状态类型 |
||||
|
function getStatusType(status: string): string { |
||||
|
const statusMap: Record<string, string> = { |
||||
|
pending: 'warning', |
||||
|
paid: 'info', |
||||
|
shipped: 'primary', |
||||
|
completed: 'success', |
||||
|
cancelled: 'info', |
||||
|
refunding: 'danger' |
||||
|
} |
||||
|
return statusMap[status] || 'info' |
||||
|
} |
||||
|
|
||||
|
// 格式化时间 |
||||
|
function formatTime(time: string): string { |
||||
|
const date = new Date(time) |
||||
|
const now = new Date() |
||||
|
const diff = now.getTime() - date.getTime() |
||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24)) |
||||
|
|
||||
|
if (days === 0) { |
||||
|
return '今天' |
||||
|
} else if (days === 1) { |
||||
|
return '昨天' |
||||
|
} else if (days < 7) { |
||||
|
return `${days}天前` |
||||
|
} else { |
||||
|
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 跳转订单详情 |
||||
|
function goOrderDetail(id: number) { |
||||
|
router.push(`/orders/${id}`) |
||||
|
} |
||||
|
|
||||
|
// 再次购买 |
||||
|
function handleRebuy(order: Order) { |
||||
|
if (order.items.length === 0) { |
||||
|
ElMessage.warning('订单商品信息异常') |
||||
|
return |
||||
|
} |
||||
|
router.push(`/product/${order.items[0].product_id}`) |
||||
|
} |
||||
|
|
||||
|
// 确认收货 |
||||
|
async function handleConfirmReceive(id: number) { |
||||
|
try { |
||||
|
await orderStore.confirmReceive(id) |
||||
|
ElMessage.success('确认收货成功') |
||||
|
await orderStore.fetchOrders({ page: currentPage.value, page_size: pageSize }) |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加载更多 |
||||
|
async function loadMore() { |
||||
|
if (orderStore.loading || !hasMore.value) return |
||||
|
currentPage.value++ |
||||
|
try { |
||||
|
await orderStore.fetchOrders({ page: currentPage.value, page_size: pageSize }) |
||||
|
} catch { |
||||
|
// 错误已在拦截器中处理 |
||||
|
currentPage.value-- // 失败时回退页码 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
currentPage.value = 1 |
||||
|
await orderStore.fetchOrders({ page: 1, page_size: pageSize }) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$accent: #1890FF; |
||||
|
$bg: #F5F5F5; |
||||
|
$text: #333; |
||||
|
$text-secondary: #666; |
||||
|
$text-hint: #999; |
||||
|
$border: #EBEDF0; |
||||
|
|
||||
|
.order-list-page { |
||||
|
min-height: 100vh; |
||||
|
background: $bg; |
||||
|
max-width: 750px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
/* ===== 顶部导航 ===== */ |
||||
|
.page-header { |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 100; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
height: 48px; |
||||
|
background: #fff; |
||||
|
border-bottom: 1px solid $border; |
||||
|
|
||||
|
.page-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
color: $text; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.page-body { |
||||
|
padding: 12px; |
||||
|
} |
||||
|
|
||||
|
/* ===== 订单卡片 ===== */ |
||||
|
.order-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.order-card { |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
padding: 16px; |
||||
|
cursor: pointer; |
||||
|
transition: box-shadow 0.2s; |
||||
|
|
||||
|
&:active { |
||||
|
box-shadow: 0 0 0 2px rgba($primary, 0.15); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.order-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
margin-bottom: 12px; |
||||
|
padding-bottom: 12px; |
||||
|
border-bottom: 1px solid $border; |
||||
|
|
||||
|
.order-info { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
|
||||
|
.order-no { |
||||
|
font-size: 14px; |
||||
|
color: $text-secondary; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.status-tag { |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.order-time { |
||||
|
font-size: 12px; |
||||
|
color: $text-hint; |
||||
|
flex-shrink: 0; |
||||
|
margin-left: 8px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.order-items { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.order-item { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
|
||||
|
.item-image { |
||||
|
width: 80px; |
||||
|
height: 80px; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
flex-shrink: 0; |
||||
|
background: $bg; |
||||
|
|
||||
|
.el-image { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.image-placeholder { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: $bg; |
||||
|
color: #ccc; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.item-info { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: space-between; |
||||
|
|
||||
|
.item-name { |
||||
|
font-size: 15px; |
||||
|
font-weight: 500; |
||||
|
color: $text; |
||||
|
line-height: 1.4; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.item-sku { |
||||
|
font-size: 12px; |
||||
|
color: $text-hint; |
||||
|
background: #F7F8FA; |
||||
|
padding: 2px 8px; |
||||
|
border-radius: 4px; |
||||
|
display: inline-block; |
||||
|
align-self: flex-start; |
||||
|
margin-top: 4px; |
||||
|
} |
||||
|
|
||||
|
.item-bottom { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-top: auto; |
||||
|
|
||||
|
.item-price { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
.item-qty { |
||||
|
font-size: 14px; |
||||
|
color: $text-hint; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.more-items { |
||||
|
font-size: 13px; |
||||
|
color: $text-hint; |
||||
|
text-align: center; |
||||
|
padding: 8px 0; |
||||
|
} |
||||
|
|
||||
|
.order-footer { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding-top: 12px; |
||||
|
border-top: 1px solid $border; |
||||
|
|
||||
|
.order-total { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 4px; |
||||
|
|
||||
|
span { |
||||
|
font-size: 13px; |
||||
|
color: $text-secondary; |
||||
|
} |
||||
|
|
||||
|
.total-amount { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.order-actions { |
||||
|
display: flex; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ===== 空状态 ===== */ |
||||
|
.empty-state { |
||||
|
padding: 80px 20px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
/* ===== 加载更多 ===== */ |
||||
|
.load-more { |
||||
|
padding: 20px 0; |
||||
|
text-align: center; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,260 @@ |
|||||
|
<template> |
||||
|
<div class="product-detail-page" v-loading="loading"> |
||||
|
<template v-if="detail"> |
||||
|
<!-- 图片轮播 --> |
||||
|
<el-carousel class="image-carousel" :autoplay="false" indicator-position="outside" height="360px"> |
||||
|
<el-carousel-item v-for="(img, idx) in carouselImages" :key="idx"> |
||||
|
<img :src="img" :alt="detail.name" class="carousel-img" /> |
||||
|
</el-carousel-item> |
||||
|
</el-carousel> |
||||
|
|
||||
|
<!-- 价格区域 --> |
||||
|
<div class="price-section"> |
||||
|
<div class="price-row"> |
||||
|
<span class="current-price"><em>¥</em>{{ displayPrice.toFixed(2) }}</span> |
||||
|
<span v-if="detail.original_price > displayPrice" class="original-price">¥{{ detail.original_price.toFixed(2) }}</span> |
||||
|
</div> |
||||
|
<div class="sales-row"> |
||||
|
<span>已售 {{ detail.sales_count }} 件</span> |
||||
|
<span>库存 {{ displayStock }} 件</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品名称 --> |
||||
|
<div class="name-section"> |
||||
|
<h1 class="product-name">{{ detail.name }}</h1> |
||||
|
<p v-if="detail.description" class="product-desc">{{ detail.description }}</p> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 体质标签 --> |
||||
|
<div v-if="detail.constitution_types?.length" class="constitution-section"> |
||||
|
<span class="section-label">适合体质</span> |
||||
|
<div class="constitution-tags"> |
||||
|
<el-tag v-for="ct in detail.constitution_types" :key="ct" type="success" effect="light" round size="large"> |
||||
|
{{ constitutionNameMap[ct] || ct }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 健康标签 --> |
||||
|
<div v-if="detail.health_tags?.length" class="health-tags-section"> |
||||
|
<span class="section-label">健康标签</span> |
||||
|
<div class="health-tags"> |
||||
|
<el-tag v-for="tag in detail.health_tags" :key="tag" effect="plain" round size="large">{{ tag }}</el-tag> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- SKU 选择 --> |
||||
|
<div v-if="detail.skus?.length" class="sku-section"> |
||||
|
<span class="section-label">规格选择</span> |
||||
|
<div class="sku-list"> |
||||
|
<div v-for="sku in detail.skus" :key="sku.id" class="sku-item" :class="{ active: selectedSku?.id === sku.id, disabled: sku.stock <= 0 }" @click="selectSku(sku)"> |
||||
|
{{ sku.name }} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- AI咨询按钮 --> |
||||
|
<div class="ai-consult-section"> |
||||
|
<el-button class="ai-consult-btn" type="success" plain size="large" round @click="handleAIConsult"> |
||||
|
<el-icon><ChatDotRound /></el-icon> |
||||
|
这款适合我吗?AI健康顾问 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 商品信息 Tabs --> |
||||
|
<el-tabs v-model="activeTab" class="detail-tabs"> |
||||
|
<el-tab-pane label="商品详情" name="detail"> |
||||
|
<div class="detail-info"> |
||||
|
<div v-if="detail.efficacy" class="info-block"><h3>功效说明</h3><p>{{ detail.efficacy }}</p></div> |
||||
|
<div v-if="detail.ingredients" class="info-block"><h3>主要成分</h3><p>{{ detail.ingredients }}</p></div> |
||||
|
<div v-if="detail.usage" class="info-block"><h3>用法用量</h3><p>{{ detail.usage }}</p></div> |
||||
|
<div v-if="detail.contraindications" class="info-block"><h3>禁忌事项</h3><p class="warning-text">{{ detail.contraindications }}</p></div> |
||||
|
<el-empty v-if="!detail.efficacy && !detail.ingredients && !detail.usage && !detail.contraindications" description="暂无详情" /> |
||||
|
</div> |
||||
|
</el-tab-pane> |
||||
|
<el-tab-pane label="规格参数" name="specs"> |
||||
|
<div class="specs-info"> |
||||
|
<el-descriptions :column="1" border size="large"> |
||||
|
<el-descriptions-item label="商品名称">{{ detail.name }}</el-descriptions-item> |
||||
|
<el-descriptions-item label="商品分类">{{ detail.category_id }}</el-descriptions-item> |
||||
|
<el-descriptions-item v-if="detail.ingredients" label="主要成分">{{ detail.ingredients }}</el-descriptions-item> |
||||
|
<el-descriptions-item label="库存">{{ detail.stock }} 件</el-descriptions-item> |
||||
|
<el-descriptions-item label="是否推荐">{{ detail.is_featured ? '是' : '否' }}</el-descriptions-item> |
||||
|
<el-descriptions-item v-if="detail.skus?.length" label="可选规格"> |
||||
|
<span v-for="(sku, idx) in detail.skus" :key="sku.id">{{ sku.name }}<template v-if="idx < detail.skus.length - 1">、</template></span> |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
</div> |
||||
|
</el-tab-pane> |
||||
|
</el-tabs> |
||||
|
</template> |
||||
|
|
||||
|
<el-empty v-if="!loading && !detail" description="商品不存在或加载失败"> |
||||
|
<el-button type="primary" @click="router.back()">返回上一页</el-button> |
||||
|
</el-empty> |
||||
|
|
||||
|
<!-- 底部固定操作栏 --> |
||||
|
<div v-if="detail" class="bottom-bar"> |
||||
|
<div class="bar-left"> |
||||
|
<div class="bar-icon-btn" @click="handleService"> |
||||
|
<el-icon :size="22"><Service /></el-icon> |
||||
|
<span>客服</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bar-right"> |
||||
|
<el-button class="cart-btn" size="large" round :disabled="displayStock <= 0" @click="handleAddToCart" :loading="addingToCart">加入购物车</el-button> |
||||
|
<el-button class="buy-btn" type="danger" size="large" round :disabled="displayStock <= 0" @click="handleBuyNow" :loading="addingToCart">立即购买</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, onMounted } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { ChatDotRound, Service } from '@element-plus/icons-vue' |
||||
|
import { getProductDetailApi } from '@/api/mall' |
||||
|
import { useMallStore } from '@/stores/mall' |
||||
|
import { consultAIAboutProduct } from '@/utils/healthAI' |
||||
|
import { useAuthCheck } from '@/utils/auth' |
||||
|
import type { ProductDetail, ProductSku } from '@/types' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
const mallStore = useMallStore() |
||||
|
const { requireAuth } = useAuthCheck() |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const detail = ref<ProductDetail | null>(null) |
||||
|
const selectedSku = ref<ProductSku | null>(null) |
||||
|
const activeTab = ref('detail') |
||||
|
const addingToCart = ref(false) |
||||
|
|
||||
|
const constitutionNameMap: Record<string, string> = { |
||||
|
pinghe: '平和质', qixu: '气虚质', yangxu: '阳虚质', yinxu: '阴虚质', |
||||
|
tanshi: '痰湿质', shire: '湿热质', xueyu: '血瘀质', qiyu: '气郁质', tebing: '特禀质' |
||||
|
} |
||||
|
|
||||
|
const carouselImages = computed(() => { |
||||
|
if (!detail.value) return [] |
||||
|
const imgs = detail.value.images?.length ? detail.value.images : [] |
||||
|
return imgs.length > 0 ? imgs : (detail.value.main_image ? [detail.value.main_image] : []) |
||||
|
}) |
||||
|
|
||||
|
const displayPrice = computed(() => selectedSku.value ? selectedSku.value.price : detail.value?.price ?? 0) |
||||
|
const displayStock = computed(() => selectedSku.value ? selectedSku.value.stock : detail.value?.stock ?? 0) |
||||
|
|
||||
|
function selectSku(sku: ProductSku) { |
||||
|
if (sku.stock <= 0) return |
||||
|
selectedSku.value = selectedSku.value?.id === sku.id ? null : sku |
||||
|
} |
||||
|
|
||||
|
async function fetchDetail() { |
||||
|
const id = Number(route.params.id) |
||||
|
if (!id) return |
||||
|
loading.value = true |
||||
|
try { |
||||
|
detail.value = await getProductDetailApi(id) as unknown as ProductDetail |
||||
|
} catch { ElMessage.error('加载商品详情失败') } |
||||
|
finally { loading.value = false } |
||||
|
} |
||||
|
|
||||
|
function handleAIConsult() { if (detail.value) consultAIAboutProduct(detail.value.id, detail.value.name) } |
||||
|
|
||||
|
async function handleAddToCart() { |
||||
|
if (!detail.value) return |
||||
|
if (!(await requireAuth('登录后即可将商品加入购物车'))) return |
||||
|
addingToCart.value = true |
||||
|
try { await mallStore.addToCart(detail.value.id, selectedSku.value?.id); ElMessage.success('已加入购物车') } |
||||
|
catch { ElMessage.error('加入购物车失败') } |
||||
|
finally { addingToCart.value = false } |
||||
|
} |
||||
|
|
||||
|
async function handleBuyNow() { |
||||
|
if (!detail.value) return |
||||
|
if (!(await requireAuth('登录后即可购买商品'))) return |
||||
|
addingToCart.value = true |
||||
|
try { await mallStore.addToCart(detail.value.id, selectedSku.value?.id); router.push('/cart') } |
||||
|
catch { ElMessage.error('操作失败,请重试') } |
||||
|
finally { addingToCart.value = false } |
||||
|
} |
||||
|
|
||||
|
function handleService() { ElMessage.info('客服功能即将上线,敬请期待') } |
||||
|
|
||||
|
onMounted(() => { fetchDetail() }) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$bg-color: #F5F5F5; |
||||
|
$text-color: #333; |
||||
|
|
||||
|
.product-detail-page { background: $bg-color; min-height: 100vh; padding-bottom: 80px; } |
||||
|
|
||||
|
.image-carousel { |
||||
|
background: #fff; |
||||
|
.carousel-img { width: 100%; height: 360px; object-fit: contain; background: #fafafa; } |
||||
|
} |
||||
|
|
||||
|
.price-section { |
||||
|
background: linear-gradient(135deg, #FFF1F0, #FFFFFF); padding: 20px 16px; |
||||
|
.price-row { display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; } |
||||
|
.current-price { font-size: 32px; font-weight: 700; color: $price-color; line-height: 1; em { font-size: 18px; font-style: normal; margin-right: 2px; } } |
||||
|
.original-price { font-size: 16px; color: #999; text-decoration: line-through; } |
||||
|
.sales-row { display: flex; gap: 24px; font-size: 14px; color: #999; } |
||||
|
} |
||||
|
|
||||
|
.name-section { |
||||
|
background: #fff; padding: 16px; margin-top: 8px; |
||||
|
.product-name { font-size: 20px; font-weight: 600; color: $text-color; line-height: 1.5; margin: 0 0 8px; } |
||||
|
.product-desc { font-size: 15px; color: #666; line-height: 1.6; } |
||||
|
} |
||||
|
|
||||
|
.constitution-section, .health-tags-section { background: #fff; padding: 16px; margin-top: 8px; } |
||||
|
.section-label { font-size: 15px; font-weight: 600; color: $text-color; display: block; margin-bottom: 12px; } |
||||
|
.constitution-tags, .health-tags { display: flex; flex-wrap: wrap; gap: 8px; :deep(.el-tag) { font-size: 14px; padding: 4px 14px; height: 32px; } } |
||||
|
|
||||
|
.sku-section { |
||||
|
background: #fff; padding: 16px; margin-top: 8px; |
||||
|
.sku-list { display: flex; flex-wrap: wrap; gap: 10px; } |
||||
|
.sku-item { |
||||
|
padding: 8px 20px; border: 2px solid #e0e0e0; border-radius: 24px; font-size: 15px; color: $text-color; cursor: pointer; transition: all 0.2s; user-select: none; |
||||
|
&:hover:not(.disabled) { border-color: $primary; color: $primary; } |
||||
|
&.active { border-color: $primary; background: rgba($primary, 0.08); color: $primary; font-weight: 600; } |
||||
|
&.disabled { color: #ccc; border-color: #eee; background: #fafafa; cursor: not-allowed; } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.ai-consult-section { background: #fff; padding: 12px 16px; margin-top: 8px; .ai-consult-btn { width: 100%; font-size: 16px; height: 44px; letter-spacing: 1px; } } |
||||
|
|
||||
|
.detail-tabs { |
||||
|
background: #fff; margin-top: 8px; padding: 0 16px 20px; |
||||
|
:deep(.el-tabs__item) { font-size: 16px; height: 50px; line-height: 50px; } |
||||
|
:deep(.el-tabs__active-bar) { background-color: $primary; } |
||||
|
:deep(.el-tabs__item.is-active) { color: $primary; } |
||||
|
} |
||||
|
|
||||
|
.detail-info .info-block { |
||||
|
margin-bottom: 24px; |
||||
|
h3 { font-size: 16px; font-weight: 600; color: $text-color; margin-bottom: 10px; padding-left: 10px; border-left: 3px solid $primary; } |
||||
|
p { font-size: 15px; color: #555; line-height: 1.8; white-space: pre-wrap; } |
||||
|
.warning-text { color: $price-color; } |
||||
|
} |
||||
|
|
||||
|
.specs-info { :deep(.el-descriptions__label) { font-size: 15px; font-weight: 500; min-width: 100px; } :deep(.el-descriptions__content) { font-size: 15px; } } |
||||
|
|
||||
|
.bottom-bar { |
||||
|
position: fixed; bottom: 0; left: 0; right: 0; height: 64px; background: #fff; |
||||
|
display: flex; align-items: center; justify-content: space-between; padding: 0 16px; |
||||
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06); z-index: 200; |
||||
|
|
||||
|
.bar-left { display: flex; gap: 20px; } |
||||
|
.bar-icon-btn { display: flex; flex-direction: column; align-items: center; gap: 2px; cursor: pointer; color: #666; font-size: 12px; &:hover { color: $primary; } } |
||||
|
.bar-right { display: flex; gap: 10px; } |
||||
|
.cart-btn { font-size: 15px; background: #FF9800; color: #fff; border: none; padding: 0 28px; height: 42px; &:disabled { background: #ddd; color: #999; } } |
||||
|
.buy-btn { font-size: 15px; background: $price-color; border-color: $price-color; padding: 0 28px; height: 42px; &:disabled { background: #ddd; color: #999; border-color: #ddd; } } |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,558 @@ |
|||||
|
<template> |
||||
|
<div class="search-page"> |
||||
|
<!-- 搜索栏 --> |
||||
|
<div class="search-header"> |
||||
|
<el-button :icon="ArrowLeft" text @click="router.back()" /> |
||||
|
<el-input |
||||
|
ref="searchInputRef" |
||||
|
v-model="keyword" |
||||
|
placeholder="搜索保健品、养生产品..." |
||||
|
:prefix-icon="Search" |
||||
|
clearable |
||||
|
size="large" |
||||
|
class="search-input" |
||||
|
@keyup.enter="handleSearch" |
||||
|
@clear="handleClear" |
||||
|
/> |
||||
|
<el-button type="primary" text size="large" class="search-btn" @click="handleSearch"> |
||||
|
搜索 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 搜索历史 --> |
||||
|
<div v-if="!hasSearched && !keyword" class="history-section"> |
||||
|
<div v-if="searchHistory.length" class="section-header"> |
||||
|
<span class="section-title">搜索历史</span> |
||||
|
<el-button type="danger" text size="small" @click="clearHistory"> |
||||
|
<el-icon><Delete /></el-icon> |
||||
|
清空 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
<div v-if="searchHistory.length" class="history-tags"> |
||||
|
<el-tag |
||||
|
v-for="(item, idx) in searchHistory" |
||||
|
:key="idx" |
||||
|
size="large" |
||||
|
effect="plain" |
||||
|
round |
||||
|
class="history-tag" |
||||
|
@click="searchByHistory(item)" |
||||
|
> |
||||
|
{{ item }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 热门搜索(占位提示) --> |
||||
|
<div class="hot-section"> |
||||
|
<span class="section-title">热门搜索</span> |
||||
|
<div class="hot-tags"> |
||||
|
<el-tag |
||||
|
v-for="tag in hotKeywords" |
||||
|
:key="tag" |
||||
|
size="large" |
||||
|
type="warning" |
||||
|
effect="light" |
||||
|
round |
||||
|
class="hot-tag" |
||||
|
@click="searchByHistory(tag)" |
||||
|
> |
||||
|
{{ tag }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 搜索结果 --> |
||||
|
<div v-if="hasSearched" class="result-section"> |
||||
|
<div v-if="products.length" class="result-header"> |
||||
|
<span>共找到 <em>{{ total }}</em> 件商品</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="result-list" v-loading="loading"> |
||||
|
<div |
||||
|
v-for="item in products" |
||||
|
:key="item.id" |
||||
|
class="product-card" |
||||
|
@click="goDetail(item.id)" |
||||
|
> |
||||
|
<div class="product-img-wrap"> |
||||
|
<img |
||||
|
v-if="item.main_image" |
||||
|
:src="item.main_image" |
||||
|
:alt="item.name" |
||||
|
class="product-img" |
||||
|
/> |
||||
|
<div v-else class="product-img-placeholder"> |
||||
|
<el-icon :size="36" color="#ccc"><Goods /></el-icon> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="product-info"> |
||||
|
<h3 class="product-name">{{ item.name }}</h3> |
||||
|
<p v-if="item.description" class="product-desc">{{ item.description }}</p> |
||||
|
<div v-if="item.health_tags?.length" class="product-tags"> |
||||
|
<el-tag |
||||
|
v-for="tag in item.health_tags.slice(0, 3)" |
||||
|
:key="tag" |
||||
|
size="small" |
||||
|
effect="light" |
||||
|
type="success" |
||||
|
round |
||||
|
> |
||||
|
{{ tag }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<div class="product-bottom"> |
||||
|
<div class="product-price"> |
||||
|
<span class="price-current">¥{{ item.price.toFixed(2) }}</span> |
||||
|
<span v-if="item.original_price > item.price" class="price-original"> |
||||
|
¥{{ item.original_price.toFixed(2) }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<span class="product-sales">已售 {{ item.sales_count }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 加载更多 --> |
||||
|
<div v-if="products.length < total" class="load-more"> |
||||
|
<el-button |
||||
|
:loading="loadingMore" |
||||
|
type="primary" |
||||
|
plain |
||||
|
round |
||||
|
size="large" |
||||
|
@click="loadMore" |
||||
|
> |
||||
|
加载更多 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 已加载全部 --> |
||||
|
<div v-else-if="products.length > 0 && products.length >= total" class="load-end"> |
||||
|
<span>— 已经到底了 —</span> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 空状态 --> |
||||
|
<el-empty |
||||
|
v-if="!loading && products.length === 0" |
||||
|
description="未找到相关商品,换个关键词试试吧" |
||||
|
> |
||||
|
<el-button type="primary" round @click="handleClear">重新搜索</el-button> |
||||
|
</el-empty> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted, nextTick } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { Search, ArrowLeft, Delete, Goods } from '@element-plus/icons-vue' |
||||
|
import { searchProductsApi } from '@/api/mall' |
||||
|
import type { ProductListItem } from '@/types' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const searchInputRef = ref() |
||||
|
|
||||
|
const keyword = ref('') |
||||
|
const loading = ref(false) |
||||
|
const loadingMore = ref(false) |
||||
|
const hasSearched = ref(false) |
||||
|
const products = ref<ProductListItem[]>([]) |
||||
|
const total = ref(0) |
||||
|
const page = ref(1) |
||||
|
const pageSize = 10 |
||||
|
|
||||
|
// 搜索历史 |
||||
|
const HISTORY_KEY = 'mall_search_history' |
||||
|
const searchHistory = ref<string[]>([]) |
||||
|
|
||||
|
// 热门搜索 |
||||
|
const hotKeywords = ['西洋参', '枸杞', '灵芝孢子粉', '阿胶', '蜂蜜', '鱼油', '钙片', '维生素'] |
||||
|
|
||||
|
// 初始化 |
||||
|
onMounted(() => { |
||||
|
loadHistory() |
||||
|
nextTick(() => { |
||||
|
searchInputRef.value?.focus() |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// 从 localStorage 加载历史 |
||||
|
function loadHistory() { |
||||
|
try { |
||||
|
const raw = localStorage.getItem(HISTORY_KEY) |
||||
|
if (raw) { |
||||
|
searchHistory.value = JSON.parse(raw) |
||||
|
} |
||||
|
} catch { |
||||
|
searchHistory.value = [] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 保存历史 |
||||
|
function saveHistory(kw: string) { |
||||
|
const trimmed = kw.trim() |
||||
|
if (!trimmed) return |
||||
|
// 去重并置顶 |
||||
|
const list = searchHistory.value.filter(item => item !== trimmed) |
||||
|
list.unshift(trimmed) |
||||
|
// 最多保留 10 条 |
||||
|
searchHistory.value = list.slice(0, 10) |
||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value)) |
||||
|
} |
||||
|
|
||||
|
// 清空历史 |
||||
|
function clearHistory() { |
||||
|
searchHistory.value = [] |
||||
|
localStorage.removeItem(HISTORY_KEY) |
||||
|
} |
||||
|
|
||||
|
// 执行搜索 |
||||
|
async function handleSearch() { |
||||
|
const kw = keyword.value.trim() |
||||
|
if (!kw) return |
||||
|
saveHistory(kw) |
||||
|
page.value = 1 |
||||
|
products.value = [] |
||||
|
hasSearched.value = true |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const res = await searchProductsApi({ keyword: kw, page: 1, page_size: pageSize }) as any |
||||
|
// 兼容 data 可能是数组或对象 |
||||
|
if (Array.isArray(res)) { |
||||
|
products.value = res |
||||
|
total.value = res.length |
||||
|
} else if (res?.items) { |
||||
|
products.value = res.items |
||||
|
total.value = res.total ?? res.items.length |
||||
|
} else if (res?.products) { |
||||
|
products.value = res.products |
||||
|
total.value = res.total ?? res.products.length |
||||
|
} else if (Array.isArray(res?.data)) { |
||||
|
products.value = res.data |
||||
|
total.value = res.total ?? res.data.length |
||||
|
} else { |
||||
|
products.value = [] |
||||
|
total.value = 0 |
||||
|
} |
||||
|
} catch { |
||||
|
products.value = [] |
||||
|
total.value = 0 |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加载更多 |
||||
|
async function loadMore() { |
||||
|
const kw = keyword.value.trim() |
||||
|
if (!kw) return |
||||
|
page.value++ |
||||
|
loadingMore.value = true |
||||
|
try { |
||||
|
const res = await searchProductsApi({ keyword: kw, page: page.value, page_size: pageSize }) as any |
||||
|
let newItems: ProductListItem[] = [] |
||||
|
if (Array.isArray(res)) { |
||||
|
newItems = res |
||||
|
} else if (res?.items) { |
||||
|
newItems = res.items |
||||
|
total.value = res.total ?? total.value |
||||
|
} else if (res?.products) { |
||||
|
newItems = res.products |
||||
|
total.value = res.total ?? total.value |
||||
|
} else if (Array.isArray(res?.data)) { |
||||
|
newItems = res.data |
||||
|
total.value = res.total ?? total.value |
||||
|
} |
||||
|
products.value.push(...newItems) |
||||
|
} catch { |
||||
|
page.value-- |
||||
|
} finally { |
||||
|
loadingMore.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 从历史搜索 |
||||
|
function searchByHistory(kw: string) { |
||||
|
keyword.value = kw |
||||
|
handleSearch() |
||||
|
} |
||||
|
|
||||
|
// 清除搜索 |
||||
|
function handleClear() { |
||||
|
keyword.value = '' |
||||
|
hasSearched.value = false |
||||
|
products.value = [] |
||||
|
total.value = 0 |
||||
|
page.value = 1 |
||||
|
nextTick(() => { |
||||
|
searchInputRef.value?.focus() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 跳转详情 |
||||
|
function goDetail(id: number) { |
||||
|
router.push(`/product/${id}`) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$primary: #52C41A; |
||||
|
$price-color: #FF4D4F; |
||||
|
$link-color: #1890FF; |
||||
|
$bg-color: #F5F5F5; |
||||
|
$text-color: #333; |
||||
|
|
||||
|
.search-page { |
||||
|
background: $bg-color; |
||||
|
min-height: 100vh; |
||||
|
} |
||||
|
|
||||
|
/* 搜索头部 */ |
||||
|
.search-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
padding: 12px 12px 12px 4px; |
||||
|
background: #fff; |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 100; |
||||
|
|
||||
|
.search-input { |
||||
|
flex: 1; |
||||
|
|
||||
|
:deep(.el-input__wrapper) { |
||||
|
border-radius: 24px; |
||||
|
font-size: 15px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.search-btn { |
||||
|
font-size: 16px; |
||||
|
font-weight: 500; |
||||
|
color: $primary; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 搜索历史 */ |
||||
|
.history-section { |
||||
|
padding: 20px 16px; |
||||
|
background: #fff; |
||||
|
margin-top: 8px; |
||||
|
} |
||||
|
|
||||
|
.section-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
margin-bottom: 14px; |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $text-color; |
||||
|
} |
||||
|
|
||||
|
.history-tags { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 10px; |
||||
|
margin-bottom: 24px; |
||||
|
} |
||||
|
|
||||
|
.history-tag { |
||||
|
cursor: pointer; |
||||
|
font-size: 14px; |
||||
|
padding: 4px 16px; |
||||
|
height: 34px; |
||||
|
transition: all 0.2s; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: $primary; |
||||
|
color: $primary; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 热门搜索 */ |
||||
|
.hot-section { |
||||
|
margin-top: 8px; |
||||
|
|
||||
|
.section-title { |
||||
|
display: block; |
||||
|
margin-bottom: 14px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.hot-tags { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 10px; |
||||
|
} |
||||
|
|
||||
|
.hot-tag { |
||||
|
cursor: pointer; |
||||
|
font-size: 14px; |
||||
|
padding: 4px 16px; |
||||
|
height: 34px; |
||||
|
transition: all 0.2s; |
||||
|
|
||||
|
&:hover { |
||||
|
opacity: 0.8; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 搜索结果 */ |
||||
|
.result-section { |
||||
|
padding: 0 12px; |
||||
|
} |
||||
|
|
||||
|
.result-header { |
||||
|
padding: 14px 4px 8px; |
||||
|
font-size: 14px; |
||||
|
color: #999; |
||||
|
|
||||
|
em { |
||||
|
font-style: normal; |
||||
|
color: $price-color; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 商品卡片 */ |
||||
|
.result-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 10px; |
||||
|
} |
||||
|
|
||||
|
.product-card { |
||||
|
display: flex; |
||||
|
background: #fff; |
||||
|
border-radius: 12px; |
||||
|
overflow: hidden; |
||||
|
cursor: pointer; |
||||
|
transition: box-shadow 0.2s; |
||||
|
|
||||
|
&:hover { |
||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.product-img-wrap { |
||||
|
width: 128px; |
||||
|
min-height: 128px; |
||||
|
flex-shrink: 0; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: #fafafa; |
||||
|
} |
||||
|
|
||||
|
.product-img { |
||||
|
width: 128px; |
||||
|
height: 128px; |
||||
|
object-fit: cover; |
||||
|
} |
||||
|
|
||||
|
.product-img-placeholder { |
||||
|
width: 128px; |
||||
|
height: 128px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: #f0f0f0; |
||||
|
} |
||||
|
|
||||
|
.product-info { |
||||
|
flex: 1; |
||||
|
padding: 14px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: space-between; |
||||
|
min-width: 0; |
||||
|
|
||||
|
.product-name { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
color: $text-color; |
||||
|
line-height: 1.4; |
||||
|
margin: 0 0 6px; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.product-desc { |
||||
|
font-size: 13px; |
||||
|
color: #999; |
||||
|
line-height: 1.4; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 1; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
margin-bottom: 6px; |
||||
|
} |
||||
|
|
||||
|
.product-tags { |
||||
|
display: flex; |
||||
|
gap: 6px; |
||||
|
margin-bottom: 8px; |
||||
|
|
||||
|
:deep(.el-tag) { |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.product-bottom { |
||||
|
display: flex; |
||||
|
align-items: baseline; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
|
||||
|
.product-price { |
||||
|
display: flex; |
||||
|
align-items: baseline; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.price-current { |
||||
|
font-size: 18px; |
||||
|
font-weight: 700; |
||||
|
color: $price-color; |
||||
|
} |
||||
|
|
||||
|
.price-original { |
||||
|
font-size: 13px; |
||||
|
color: #bbb; |
||||
|
text-decoration: line-through; |
||||
|
} |
||||
|
|
||||
|
.product-sales { |
||||
|
font-size: 12px; |
||||
|
color: #bbb; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 加载更多 */ |
||||
|
.load-more { |
||||
|
text-align: center; |
||||
|
padding: 20px 0 30px; |
||||
|
|
||||
|
.el-button { |
||||
|
width: 200px; |
||||
|
font-size: 15px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.load-end { |
||||
|
text-align: center; |
||||
|
padding: 20px 0 30px; |
||||
|
font-size: 14px; |
||||
|
color: #ccc; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,14 @@ |
|||||
|
{ |
||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json", |
||||
|
"compilerOptions": { |
||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |
||||
|
"types": ["vite/client"], |
||||
|
"strict": true, |
||||
|
"noUnusedLocals": true, |
||||
|
"noUnusedParameters": true, |
||||
|
"erasableSyntaxOnly": true, |
||||
|
"noFallthroughCasesInSwitch": true, |
||||
|
"noUncheckedSideEffectImports": true |
||||
|
}, |
||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
"files": [], |
||||
|
"references": [ |
||||
|
{ "path": "./tsconfig.app.json" }, |
||||
|
{ "path": "./tsconfig.node.json" } |
||||
|
] |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
{ |
||||
|
"compilerOptions": { |
||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |
||||
|
"target": "ES2023", |
||||
|
"lib": ["ES2023"], |
||||
|
"module": "ESNext", |
||||
|
"types": ["node"], |
||||
|
"skipLibCheck": true, |
||||
|
"moduleResolution": "bundler", |
||||
|
"allowImportingTsExtensions": true, |
||||
|
"verbatimModuleSyntax": true, |
||||
|
"moduleDetection": "force", |
||||
|
"noEmit": true, |
||||
|
"strict": true, |
||||
|
"noUnusedLocals": true, |
||||
|
"noUnusedParameters": true, |
||||
|
"erasableSyntaxOnly": true, |
||||
|
"noFallthroughCasesInSwitch": true, |
||||
|
"noUncheckedSideEffectImports": true |
||||
|
}, |
||||
|
"include": ["vite.config.ts"] |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { defineConfig } from 'vite' |
||||
|
import vue from '@vitejs/plugin-vue' |
||||
|
import { fileURLToPath, URL } from 'node:url' |
||||
|
|
||||
|
export default defineConfig({ |
||||
|
plugins: [vue()], |
||||
|
resolve: { |
||||
|
alias: { |
||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)) |
||||
|
} |
||||
|
}, |
||||
|
server: { |
||||
|
port: 5174, |
||||
|
host: true |
||||
|
} |
||||
|
}) |
||||
Loading…
Reference in new issue