Browse Source

feat(mall): 商城前端独立项目 Vue3+TS+Element Plus,公开浏览+按需登录

Co-authored-by: Cursor <cursoragent@cursor.com>
master
dark 1 month ago
parent
commit
a6e646a9e3
  1. 13
      mall/index.html
  2. 3732
      mall/package-lock.json
  3. 29
      mall/package.json
  4. 1
      mall/public/vite.svg
  5. 10
      mall/src/App.vue
  6. 16
      mall/src/api/auth.ts
  7. 180
      mall/src/api/mall.ts
  8. 58
      mall/src/api/request.ts
  9. 23
      mall/src/main.ts
  10. 107
      mall/src/router/index.ts
  11. 39
      mall/src/stores/auth.ts
  12. 23
      mall/src/stores/constitution.ts
  13. 113
      mall/src/stores/mall.ts
  14. 65
      mall/src/stores/member.ts
  15. 87
      mall/src/stores/order.ts
  16. 51
      mall/src/styles/index.scss
  17. 232
      mall/src/types/index.ts
  18. 37
      mall/src/utils/auth.ts
  19. 74
      mall/src/utils/healthAI.ts
  20. 265
      mall/src/views/auth/LoginView.vue
  21. 241
      mall/src/views/layout/MallLayout.vue
  22. 249
      mall/src/views/mall/AddressListView.vue
  23. 268
      mall/src/views/mall/CartView.vue
  24. 490
      mall/src/views/mall/CategoryView.vue
  25. 706
      mall/src/views/mall/CheckoutView.vue
  26. 511
      mall/src/views/mall/MallHomeView.vue
  27. 667
      mall/src/views/mall/MemberView.vue
  28. 730
      mall/src/views/mall/OrderDetailView.vue
  29. 440
      mall/src/views/mall/OrderListView.vue
  30. 260
      mall/src/views/mall/ProductDetailView.vue
  31. 558
      mall/src/views/mall/SearchView.vue
  32. 14
      mall/tsconfig.app.json
  33. 7
      mall/tsconfig.json
  34. 22
      mall/tsconfig.node.json
  35. 16
      mall/vite.config.ts

13
mall/index.html

@ -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>

3732
mall/package-lock.json

File diff suppressed because it is too large

29
mall/package.json

@ -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"
}
}

1
mall/public/vite.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

10
mall/src/App.vue

@ -0,0 +1,10 @@
<template>
<router-view />
</template>
<style>
html, body, #app {
height: 100%;
margin: 0;
}
</style>

16
mall/src/api/auth.ts

@ -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')
}

180
mall/src/api/mall.ts

@ -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 })
}

58
mall/src/api/request.ts

@ -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

23
mall/src/main.ts

@ -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')

107
mall/src/router/index.ts

@ -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

39
mall/src/stores/auth.ts

@ -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 }
})

23
mall/src/stores/constitution.ts

@ -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 }
})

113
mall/src/stores/mall.ts

@ -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
}
})

65
mall/src/stores/member.ts

@ -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
}
})

87
mall/src/stores/order.ts

@ -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
}
})

51
mall/src/styles/index.scss

@ -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;
}

232
mall/src/types/index.ts

@ -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: '💎' }
}

37
mall/src/utils/auth.ts

@ -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 }
}

74
mall/src/utils/healthAI.ts

@ -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')
}
}

265
mall/src/views/auth/LoginView.vue

@ -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>

241
mall/src/views/layout/MallLayout.vue

@ -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>

249
mall/src/views/mall/AddressListView.vue

@ -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>

268
mall/src/views/mall/CartView.vue

@ -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>

490
mall/src/views/mall/CategoryView.vue

@ -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>

706
mall/src/views/mall/CheckoutView.vue

@ -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>

511
mall/src/views/mall/MallHomeView.vue

@ -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>

667
mall/src/views/mall/MemberView.vue

@ -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>

730
mall/src/views/mall/OrderDetailView.vue

@ -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>

440
mall/src/views/mall/OrderListView.vue

@ -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>

260
mall/src/views/mall/ProductDetailView.vue

@ -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>

558
mall/src/views/mall/SearchView.vue

@ -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>

14
mall/tsconfig.app.json

@ -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"]
}

7
mall/tsconfig.json

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
mall/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"]
}

16
mall/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…
Cancel
Save