清除数据

This commit is contained in:
2026-05-06 23:30:54 +08:00
parent 17a5734d67
commit d16fc36264
149 changed files with 6691 additions and 5575 deletions

View File

@@ -0,0 +1,7 @@
# 开发环境配置
VITE_APP_TITLE=社区生鲜团购系统
VITE_API_BASE_URL=
VITE_WS_URL=ws://localhost:8080/ws
VITE_UPLOAD_URL=http://localhost:8080/upload
VITE_TIMEOUT=10000
VITE_USE_MOCK=false

View File

@@ -0,0 +1,7 @@
# 生产环境配置
VITE_APP_TITLE=社区生鲜团购系统
VITE_API_BASE_URL=https://api.flashsale.com
VITE_WS_URL=wss://api.flashsale.com/ws
VITE_UPLOAD_URL=https://api.flashsale.com/upload
VITE_TIMEOUT=10000
VITE_USE_MOCK=false

View File

@@ -0,0 +1 @@
cache=/tmp/npm-cache

View File

@@ -0,0 +1,202 @@
# 社区生鲜团购系统前端 (Group Buy Frontend)
基于 Vue 3 + Vite + TypeScript 构建的现代化社区生鲜团购系统前端应用。
## 🚀 技术栈
- **框架**: Vue 3.4 (Composition API)
- **构建工具**: Vite 5.0
- **编程语言**: TypeScript 5.3
- **UI组件库**: Element Plus 2.4
- **状态管理**: Pinia 2.1
- **路由**: Vue Router 4.2
- **HTTP客户端**: Axios 1.6
- **样式**: TailwindCSS 3.4 + Sass
- **图表**: ECharts 5.4
- **工具库**: VueUse, Day.js, Lodash
## ✨ 功能特性
### 用户端功能
- 🏠 **首页展示**: 轮播图、限时活动、热门商品推荐
- 🔐 **用户认证**: 登录、注册、个人中心管理
-**限时活动抢购**: 实时倒计时、库存显示、防重复提交
- 🛍️ **商品浏览**: 分类筛选、价格排序、关键词搜索
- 🛒 **购物车**: 商品管理、批量操作、结算功能
- 📦 **订单管理**: 订单列表、详情查看、状态跟踪
### 管理后台
- 📊 **数据仪表盘**: 实时统计、图表展示
- 📝 **内容管理**: 商品、限时活动、订单、用户管理
- 📈 **数据分析**: 销售趋势、用户行为分析
### 高级特性
- 🔔 **实时通知**: WebSocket消息推送、消息中心
- 🔍 **智能搜索**: 搜索历史、热门推荐、实时建议
- 📸 **图片上传**: 拖拽上传、预览下载、进度显示
- 📊 **数据导出**: Excel导出、批量处理
- 🎨 **响应式设计**: 移动端适配、深色模式(开发中)
## 📁 项目结构
```
community-fresh-group-buy-frontend/
├── src/
│ ├── api/ # API接口封装
│ ├── assets/ # 静态资源
│ ├── components/ # 组件
│ │ ├── common/ # 通用组件
│ │ ├── business/ # 业务组件
│ │ └── layout/ # 布局组件
│ ├── composables/ # 组合式函数
│ ├── layouts/ # 页面布局
│ ├── pages/ # 页面视图
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia状态管理
│ ├── styles/ # 全局样式
│ ├── types/ # TypeScript类型定义
│ └── utils/ # 工具函数
├── public/ # 公共资源
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
└── vite.config.ts # Vite配置
```
## 🔧 安装使用
### 环境要求
- Node.js >= 16.0
- npm >= 8.0 或 yarn >= 1.22
### 安装依赖
```bash
npm install
# 或
yarn install
```
### 开发环境
```bash
npm run dev
# 或
yarn dev
```
访问 http://localhost:3000
### 生产构建
```bash
npm run build
# 或
yarn build
```
### 预览构建结果
```bash
npm run preview
# 或
yarn preview
```
## 🔑 测试账号
- **普通用户**: user / 123456
- **管理员**: admin / 123456
## 📝 配置说明
### 环境变量
修改 `.env.development``.env.production` 文件:
```env
# API基础路径
VITE_API_BASE_URL=http://localhost:8080
# WebSocket地址
VITE_WS_URL=ws://localhost:8080/ws
# 上传地址
VITE_UPLOAD_URL=http://localhost:8080/upload
```
### 代理配置
`vite.config.ts` 中配置开发环境代理:
```typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
```
## 🚢 部署
### Nginx配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/dist;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend-server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Docker部署
```dockerfile
FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
## 🎯 性能优化
- **路由懒加载**: 减少首屏加载时间
- **组件缓存**: keep-alive缓存页面状态
- **图片懒加载**: 按需加载图片资源
- **代码分割**: 自动分包优化
- **Gzip压缩**: 减少传输体积
- **CDN加速**: 静态资源CDN分发
## 📄 许可证
MIT License
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📧 联系我们
- 邮箱: contact@community-fresh-groupbuy.example
- 官网: https://community-fresh-groupbuy.example
---
Made with ❤️ by Group Buy Team

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="/favicon.ico" rel="icon">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>社区生鲜团购系统</title>
<meta content="社区生鲜团购系统,提供商品浏览、拼团下单、订单管理和后台运营能力" name="description">
<meta content="拼团,团购,社区生鲜,community fresh group buying" name="keywords">
</head>
<body>
<div id="app"></div>
<script src="/src/main.ts" type="module"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
{
"name": "community-fresh-group-buy-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.1",
"dayjs": "^1.11.10",
"@vueuse/core": "^10.7.1",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.8",
"lodash-es": "^4.17.21",
"@types/lodash-es": "^4.17.12",
"xlsx": "^0.18.5",
"file-saver": "^2.0.5",
"@types/file-saver": "^2.0.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11",
"typescript": "^5.3.3",
"vue-tsc": "^1.8.27",
"@types/node": "^20.11.5",
"sass": "^1.70.0",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.1",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"prettier": "^3.2.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@playwright/test": "^1.52.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,26 @@
<template>
<el-config-provider :locale="zhCn">
<router-view/>
</el-config-provider>
</template>
<script lang="ts" setup>
import {onMounted} from 'vue'
import {ElConfigProvider} from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import {useUserStore} from '@/stores/user'
const userStore = useUserStore()
onMounted(() => {
// 初始化用户信息
userStore.getUserInfo()
})
</script>
<style>
#app {
min-height: 100vh;
background: transparent;
}
</style>

View File

@@ -0,0 +1,34 @@
import request from './request'
import type {FlashSale, FlashSaleParams} from '@/types/flashsale'
export const flashSaleApi = {
// 获取限时活动列表
getList(params?: FlashSaleParams) {
return request.get<any, { list: FlashSale[], total: number }>('/api/flashsales', {params})
},
// 获取限时活动详情
getDetail(id: number) {
return request.get<any, FlashSale>(`/api/flashsales/${id}`)
},
// 参与限时
participate(flashSaleId: number, quantity: number = 1) {
return request.post('/api/flashsales/participate', {
flashSaleId,
quantity
})
},
// 获取正在进行的限时活动
getActive() {
return request.get<any, FlashSale[]>('/api/flashsales/active')
},
// 获取即将开始的限时活动
getUpcoming() {
return request.get<any, FlashSale[]>('/api/flashsales/upcoming')
}
}
export default flashSaleApi

View File

@@ -0,0 +1,52 @@
import {request} from '../request'
import type {ApiResponse} from '@/types/api'
export interface AddressItem {
id: number
userId?: number
name: string
phone: string
province: string
city: string
district: string
address: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
export interface SaveAddressParams {
name: string
phone: string
province: string
city: string
district: string
address: string
isDefault?: boolean
}
export const addressApi = {
getList(): Promise<ApiResponse<AddressItem[]>> {
return request.get('/api/address')
},
getDefault(): Promise<ApiResponse<AddressItem>> {
return request.get('/api/address/default')
},
create(data: SaveAddressParams): Promise<ApiResponse<AddressItem>> {
return request.post('/api/address', data)
},
update(id: number, data: SaveAddressParams): Promise<ApiResponse<AddressItem>> {
return request.put(`/api/address/${id}`, data)
},
setDefault(id: number): Promise<ApiResponse<AddressItem>> {
return request.post(`/api/address/${id}/default`)
},
delete(id: number): Promise<ApiResponse> {
return request.delete(`/api/address/${id}`)
},
}

View File

@@ -0,0 +1,220 @@
import {request} from '../request'
import type {ApiResponse} from '@/types/api'
import type {
AdminDashboardStats,
AdminFavoriteRow,
AdminFavoriteStats,
AdminFlashSaleStats,
AdminHotProductRow,
AdminOrderRow,
AdminOrderStats,
AdminProductRow,
AdminProductStats,
AdminRecentOrderRow,
AdminReviewRow,
AdminReviewStats,
AdminUserRow,
AdminUserStats,
MonitorSystemStatus,
RedisNodeStatus,
} from '@/types/admin'
import {
normalizeAdminHotProduct,
normalizeAdminOrder,
normalizeAdminProduct,
normalizeAdminRecentOrder,
normalizeAdminUser,
} from '@/utils/normalizers'
export const adminApi = {
getDashboardStats(): Promise<ApiResponse<AdminDashboardStats>> {
return request.get('/api/admin/dashboard/stats')
},
getUserStats(): Promise<ApiResponse<AdminUserStats>> {
return request.get('/api/admin/users/stats')
},
getOrderStats(): Promise<ApiResponse<AdminOrderStats>> {
return request.get('/api/admin/orders/stats')
},
getProductStats(): Promise<ApiResponse<AdminProductStats>> {
return request.get('/api/admin/products/stats')
},
getFlashSaleStats(): Promise<ApiResponse<AdminFlashSaleStats>> {
return request.get('/api/admin/flashsales/stats')
},
getRecentOrders(limit = 10): Promise<ApiResponse<AdminRecentOrderRow[]>> {
return request.get<ApiResponse<any[]>>('/api/admin/orders/recent', {limit}).then((res) => ({
...res,
data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminRecentOrder(item)) : [],
}))
},
getHotProducts(limit = 5): Promise<ApiResponse<AdminHotProductRow[]>> {
return request.get<ApiResponse<any[]>>('/api/admin/products/hot', {limit}).then((res) => ({
...res,
data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminHotProduct(item)) : [],
}))
},
getUsers(params: { page: number; size: number; keyword?: string; status?: number | '' }): Promise<ApiResponse<{
users: AdminUserRow[];
total: number;
totalPages: number;
currentPage: number;
size: number
}>> {
const query = {
page: params.page,
size: params.size,
keyword: params.keyword,
status: params.status === '' ? undefined : params.status
}
return request.get<ApiResponse<Record<string, any>>>('/api/admin/users', query).then((res) => ({
...res,
data: {
users: Array.isArray(res.data.users) ? res.data.users.map((item) => normalizeAdminUser(item)) : [],
total: Number(res.data.total || 0),
totalPages: Number(res.data.totalPages || 0),
currentPage: Number(res.data.currentPage || params.page),
size: Number(res.data.size || params.size),
},
}))
},
deleteUser(id: number): Promise<ApiResponse> {
return request.delete(`/api/admin/users/${id}`)
},
getOrders(params: { page: number; size: number; keyword?: string; status?: string | '' }): Promise<ApiResponse<{
orders: AdminOrderRow[];
total: number;
totalPages: number;
currentPage: number;
size: number
}>> {
const query = {
page: params.page,
size: params.size,
keyword: params.keyword,
status: params.status === '' ? undefined : params.status
}
return request.get<ApiResponse<Record<string, any>>>('/api/admin/orders', query).then((res) => ({
...res,
data: {
orders: Array.isArray(res.data.orders) ? res.data.orders.map((item) => normalizeAdminOrder(item)) : [],
total: Number(res.data.total || 0),
totalPages: Number(res.data.totalPages || 0),
currentPage: Number(res.data.currentPage || params.page),
size: Number(res.data.size || params.size),
},
}))
},
getProducts(params: {
page: number;
size: number;
keyword?: string;
category?: string;
status?: number | ''
}): Promise<ApiResponse<{
products: AdminProductRow[];
total: number;
totalPages: number;
currentPage: number;
size: number
}>> {
const query = {
page: params.page,
size: params.size,
keyword: params.keyword,
category: params.category,
status: params.status === '' ? undefined : params.status
}
return request.get<ApiResponse<Record<string, any>>>('/api/admin/products', query).then((res) => ({
...res,
data: {
products: Array.isArray(res.data.products) ? res.data.products.map((item) => normalizeAdminProduct(item)) : [],
total: Number(res.data.total || 0),
totalPages: Number(res.data.totalPages || 0),
currentPage: Number(res.data.currentPage || params.page),
size: Number(res.data.size || params.size),
},
}))
},
getSystemStatus(): Promise<ApiResponse<MonitorSystemStatus>> {
return request.get('/api/admin/monitor/system')
},
getRedisStatus(): Promise<ApiResponse<RedisNodeStatus[]>> {
return request.get('/api/admin/monitor/redis')
},
getProduct(id: number): Promise<ApiResponse<AdminProductRow>> {
return request.get<ApiResponse<any>>(`/api/admin/products/${id}`).then((res) => ({
...res,
data: normalizeAdminProduct(res.data)
}))
},
createProduct(data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
return request.post<ApiResponse<any>>('/api/admin/products', data).then((res) => ({
...res,
data: normalizeAdminProduct(res.data)
}))
},
updateProduct(id: number, data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
return request.put<ApiResponse<any>>(`/api/admin/products/${id}`, data).then((res) => ({
...res,
data: res.data ? normalizeAdminProduct(res.data) : undefined,
}))
},
deleteProduct(id: number): Promise<ApiResponse> {
return request.delete(`/api/admin/products/${id}`)
},
getReviewStats(): Promise<ApiResponse<AdminReviewStats>> {
return request.get('/api/admin/reviews/stats')
},
getFavoriteStats(): Promise<ApiResponse<AdminFavoriteStats>> {
return request.get('/api/admin/favorites/stats')
},
getReviews(params: { page: number; size: number; keyword?: string }): Promise<ApiResponse<{
reviews: AdminReviewRow[];
total: number;
totalPages: number;
currentPage: number;
size: number
}>> {
return request.get<ApiResponse<Record<string, any>>>('/api/admin/reviews', params).then((res) => ({
...res,
data: {
reviews: Array.isArray(res.data.reviews) ? (res.data.reviews as AdminReviewRow[]) : [],
total: Number(res.data.total || 0),
totalPages: Number(res.data.totalPages || 0),
currentPage: Number(res.data.currentPage || params.page),
size: Number(res.data.size || params.size),
},
}))
},
updateReview(id: number, data: { status?: number; adminReply?: string }): Promise<ApiResponse> {
return request.put(`/api/admin/reviews/${id}`, data)
},
deleteReview(id: number): Promise<ApiResponse> {
return request.delete(`/api/admin/reviews/${id}`)
},
getFavorites(params: { page: number; size: number; keyword?: string }): Promise<ApiResponse<{
favorites: AdminFavoriteRow[];
total: number;
totalPages: number;
currentPage: number;
size: number
}>> {
return request.get<ApiResponse<Record<string, any>>>('/api/admin/favorites', params).then((res) => ({
...res,
data: {
favorites: Array.isArray(res.data.favorites) ? (res.data.favorites as AdminFavoriteRow[]) : [],
total: Number(res.data.total || 0),
totalPages: Number(res.data.totalPages || 0),
currentPage: Number(res.data.currentPage || params.page),
size: Number(res.data.size || params.size),
},
}))
},
deleteFavorite(id: number): Promise<ApiResponse> {
return request.delete(`/api/admin/favorites/${id}`)
},
migrateLegacyOrderItems(): Promise<ApiResponse<{ totalOrders: number; migrated: number; skipped: number }>> {
return request.post('/api/admin/orders/migrate-items')
},
}

View File

@@ -0,0 +1,55 @@
import {request} from '../request'
import type {ApiResponse, CartItem} from '@/types/api'
import {normalizeCartItems, normalizeOrder} from '@/utils/normalizers'
export const cartApi = {
// 获取购物车
getCart(): Promise<ApiResponse<CartItem[]>> {
return request.get<ApiResponse<any>>('/api/cart').then((res) => ({
...res,
data: normalizeCartItems(res.data),
}))
},
// 添加到购物车
addToCart(data: {
productId: number;
quantity: number
}): Promise<ApiResponse> {
return request.post('/api/cart/add', data)
},
// 更新数量
updateQuantity(itemId: string, quantity: number): Promise<ApiResponse> {
return request.put('/api/cart/update', {productId: Number(itemId), quantity})
},
// 删除商品
removeItem(itemId: string): Promise<ApiResponse> {
return request.delete('/api/cart/remove', {productId: Number(itemId)})
},
// 批量删除
batchRemove(ids: string[]): Promise<ApiResponse> {
return request.delete('/api/cart/batch-remove', {productIds: ids.map(Number)})
},
// 清空购物车
clearCart(): Promise<ApiResponse> {
return request.delete('/api/cart/clear')
},
// 获取购物车数量
getCount(): Promise<ApiResponse<{ count: number }>> {
return request.get('/api/cart/count')
},
checkout(ids?: string[]): Promise<ApiResponse<any>> {
return request.post<ApiResponse<any>>('/api/cart/checkout', {
productIds: ids?.map(Number),
}).then((res) => ({
...res,
data: normalizeOrder(res.data),
}))
},
}

View File

@@ -0,0 +1,31 @@
import {request} from '../request'
import type {ApiResponse} from '@/types/api'
export interface FavoriteItem {
id: number
userId: number
productId: number
productName: string
productImageUrl: string
productPrice: number
productCategory: string
createdAt: string
}
export const favoriteApi = {
getList(): Promise<ApiResponse<FavoriteItem[]>> {
return request.get('/api/favorite')
},
getCount(): Promise<ApiResponse<{ count: number }>> {
return request.get('/api/favorite/count')
},
check(productId: number): Promise<ApiResponse<{ favorited: boolean }>> {
return request.get('/api/favorite/check', {productId})
},
toggle(productId: number): Promise<ApiResponse<{ favorited: boolean }>> {
return request.post('/api/favorite/toggle', {productId})
},
}

View File

@@ -0,0 +1,164 @@
import {request} from '../request'
import type {ApiResponse, FlashSale, PageParams, PageResponse} from '@/types/api'
import {mapOrderStatus, normalizeFlashSale, normalizePage} from '@/utils/normalizers'
const flashSaleStatusToCode = (status?: string) => {
if (status === 'UPCOMING') return 1
if (status === 'ACTIVE') return 2
if (status === 'ENDED') return 3
if (status === 'PAUSED') return 4
return undefined
}
const flashSaleSortField = (sort?: string) => {
if (sort === 'flashPrice') return 'flashPrice'
if (sort === 'endTime') return 'endTime'
return 'startTime'
}
export const flashsaleApi = {
// 获取限时活动统计信息(即将开始/正在进行/我的参与/抢购成功)
getStatistics(): Promise<ApiResponse<{ upcoming: number; active: number; participated: number; success: number }>> {
return request.get('/api/flashsale/statistics')
},
// 获取限时活动列表
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<FlashSale>>> {
return request.post<ApiResponse<Record<string, any>>>('/api/flashsale/list', {
status: flashSaleStatusToCode(params?.status),
page: params?.page ?? 0,
size: params?.size ?? 10,
sortBy: flashSaleSortField(params?.sort),
sortDirection: params?.order || 'asc',
}).then((res) => ({
...res,
data: normalizePage(res.data, normalizeFlashSale),
}))
},
// 获取正在进行的限时活动
getActive(limit?: number): Promise<ApiResponse<FlashSale[]>> {
return request.get<ApiResponse<any[]>>('/api/flashsale/active').then((res) => ({
...res,
data: (Array.isArray(res.data) ? res.data : [])
.map((item) => normalizeFlashSale(item))
.slice(0, limit ?? Number.MAX_SAFE_INTEGER),
}))
},
// 获取限时活动详情
getDetail(id: number): Promise<ApiResponse<FlashSale>> {
return request.get<ApiResponse<any>>(`/api/flashsale/${id}`).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
// 参与限时
participate(data: {
flashSaleId: number;
quantity: number;
timestamp?: number;
}): Promise<ApiResponse<{ orderId: number }>> {
return request.post<ApiResponse<any>>('/api/flashsale/participate', data).then((res) => ({
...res,
data: {
orderId: Number(res.data?.orderId || res.data?.id || 0),
},
}))
},
// 获取用户参与记录
getUserRecords(): Promise<ApiResponse<any[]>> {
return request.post<ApiResponse<Record<string, any>>>('/api/order/my-orders', {
orderType: 2,
page: 0,
size: 100,
sortBy: 'createdAt',
sortDirection: 'desc',
}).then((res) => {
const content = Array.isArray(res.data?.content) ? res.data.content : []
return {
...res,
data: content.map((item: Record<string, any>) => ({
id: item.id,
success: mapOrderStatus(item.status) !== 'CANCELLED',
status: item.status,
})),
}
})
},
// 检查用户是否可以参与
checkEligibility(flashSaleId: number): Promise<ApiResponse<{
eligible: boolean;
reason?: string;
remainingQuota?: number;
}>> {
return this.getDetail(flashSaleId).then((res) => {
const eligible = res.data.status === 'ACTIVE' && res.data.remainingStock > 0
return {
code: 0,
success: true,
message: '检查成功',
data: {
eligible,
reason: eligible ? '' : '活动未开始、已结束或库存不足',
remainingQuota: res.data.limitPerUser,
},
}
})
},
create(data: {
productId: number
flashPrice: number
flashStock: number
startTime: string
endTime: string
}): Promise<ApiResponse<FlashSale>> {
return request.post<ApiResponse<any>>('/api/flashsale/create', data).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<FlashSale>> {
return request.put<ApiResponse<any>>(`/api/flashsale/${id}`, data).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
delete(id: number): Promise<ApiResponse> {
return request.delete(`/api/flashsale/${id}`)
},
publish(id: number): Promise<ApiResponse<FlashSale>> {
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/publish`).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
pause(id: number): Promise<ApiResponse<FlashSale>> {
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/pause`).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
resume(id: number): Promise<ApiResponse<FlashSale>> {
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/resume`).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
end(id: number): Promise<ApiResponse<FlashSale>> {
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/end`).then((res) => ({
...res,
data: normalizeFlashSale(res.data),
}))
},
}

View File

@@ -0,0 +1,115 @@
import {request} from '../request'
import type {
ApiResponse,
GroupBuying,
GroupBuyingGroup,
GroupBuyingStatistics,
PageParams,
PageResponse
} from '@/types/api'
import {normalizeGroupBuying, normalizeGroupBuyingGroup, normalizePage} from '@/utils/normalizers'
const groupBuyingStatusToCode = (status?: string) => {
if (status === 'DRAFT') return 0
if (status === 'UPCOMING') return 1
if (status === 'ACTIVE') return 2
if (status === 'ENDED') return 3
return undefined
}
export const groupbuyingApi = {
getStatistics(): Promise<ApiResponse<GroupBuyingStatistics>> {
return request.get('/api/groupbuying/statistics')
},
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<GroupBuying>>> {
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/list', {
status: groupBuyingStatusToCode(params?.status),
page: params?.page ?? 0,
size: params?.size ?? 10,
}).then((res) => ({
...res,
data: normalizePage(res.data, normalizeGroupBuying),
}))
},
getDetail(id: number): Promise<ApiResponse<GroupBuying>> {
return request.get<ApiResponse<any>>(`/api/groupbuying/${id}`).then((res) => ({
...res,
data: normalizeGroupBuying(res.data),
}))
},
getGroups(id: number, params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
return request.get<ApiResponse<Record<string, any>>>(`/api/groupbuying/${id}/groups`, {
page: params?.page ?? 0,
size: params?.size ?? 10,
}).then((res) => ({
...res,
data: normalizePage(res.data, normalizeGroupBuyingGroup),
}))
},
joinGroup(data: { groupBuyingId: number; groupId?: number }): Promise<ApiResponse<{
success: boolean
message: string
groupId: number
groupNo: string
orderId: number
}>> {
return request.post('/api/groupbuying/join', data)
},
getGroupDetail(groupId: number): Promise<ApiResponse<GroupBuyingGroup>> {
return request.get<ApiResponse<any>>(`/api/groupbuying/group/${groupId}`).then((res) => ({
...res,
data: normalizeGroupBuyingGroup(res.data),
}))
},
cancelMembership(groupId: number): Promise<ApiResponse> {
return request.post(`/api/groupbuying/group/${groupId}/cancel`)
},
getMyGroups(params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/my-groups', {
page: params?.page ?? 0,
size: params?.size ?? 10,
}).then((res) => ({
...res,
data: normalizePage(res.data, normalizeGroupBuyingGroup),
}))
},
// Admin
create(data: {
productId: number
groupPrice: number
requiredMembers: number
durationMinutes: number
totalStock: number
maxPerUser: number
startTime: string
endTime: string
}): Promise<ApiResponse<GroupBuying>> {
return request.post<ApiResponse<any>>('/api/groupbuying/admin/create', data).then((res) => ({
...res,
data: normalizeGroupBuying(res.data),
}))
},
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<GroupBuying>> {
return request.put<ApiResponse<any>>(`/api/groupbuying/admin/${id}`, data).then((res) => ({
...res,
data: normalizeGroupBuying(res.data),
}))
},
delete(id: number): Promise<ApiResponse> {
return request.delete(`/api/groupbuying/admin/${id}`)
},
preloadAll(): Promise<ApiResponse> {
return request.post('/api/groupbuying/admin/preload-all')
},
}

View File

@@ -0,0 +1,45 @@
import {request} from '../request'
export interface NotificationItem {
id: number
userId: number
type: 'flashsale' | 'order' | 'system'
title: string
message: string
link?: string
read: boolean
createdAt: string
}
interface ApiRes<T = any> {
success: boolean
message?: string
data: T
}
export const notificationApi = {
/** 获取通知列表 */
getList(type?: string): Promise<ApiRes<NotificationItem[]>> {
return request.get('/api/notification/list', type ? {type} : undefined)
},
/** 获取未读数量 */
getUnreadCount(): Promise<ApiRes<number>> {
return request.get('/api/notification/unread-count')
},
/** 标记单条已读 */
markAsRead(id: number): Promise<ApiRes> {
return request.put(`/api/notification/${id}/read`)
},
/** 全部标记已读 */
markAllAsRead(): Promise<ApiRes> {
return request.put('/api/notification/read-all')
},
/** 清空所有通知 */
clearAll(): Promise<ApiRes> {
return request.delete('/api/notification/clear')
}
}

View File

@@ -0,0 +1,145 @@
import {request} from '../request'
import type {ApiResponse, Order, PageParams, PageResponse} from '@/types/api'
import {normalizeOrder} from '@/utils/normalizers'
const orderStatusToCode = (status?: string) => {
if (status === 'PENDING') return 1
if (status === 'PAID') return 2
if (status === 'SHIPPED') return 3
if (status === 'COMPLETED') return 4
if (status === 'CANCELLED') return 5
return undefined
}
const aggregateOrders = (rawOrders: Array<Record<string, any>>): Order[] => {
const groups = new Map<string, Array<Record<string, any>>>()
rawOrders.forEach((item) => {
const key = item.groupNo || item.orderNo || String(item.id)
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(item)
})
return Array.from(groups.values()).map((group) => {
const [anchor] = group
const normalizedAnchor = normalizeOrder(anchor)
const items = group.flatMap((item) => normalizeOrder(item).items)
const totalAmount = items.reduce((sum, item) => sum + item.subtotal, 0)
return {
...normalizedAnchor,
orderNo: anchor.groupNo || normalizedAnchor.orderNo,
totalAmount,
paymentAmount: totalAmount,
items,
}
})
}
export const orderApi = {
create(data: {
items: Array<{ productId: number; quantity: number }>
addressId?: number
remark?: string
}): Promise<ApiResponse<Order>> {
const [firstItem] = data.items
return request.post<ApiResponse<any>>('/api/order/create', {
productId: firstItem.productId,
quantity: firstItem.quantity,
remark: data.remark,
}).then((res) => ({
...res,
data: normalizeOrder(res.data),
}))
},
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<Order>>> {
return request.post<ApiResponse<Record<string, any>>>('/api/order/my-orders', {
status: orderStatusToCode(params?.status),
page: params?.page ?? 0,
size: params?.size ?? 10,
sortBy: params?.sort || 'createdAt',
sortDirection: params?.order || 'desc',
}).then((res) => {
const rawContent = Array.isArray(res.data.content) ? res.data.content : []
const content = aggregateOrders(rawContent)
return {
...res,
data: {
content,
totalElements: Number(res.data.totalElements || content.length),
totalPages: Number(res.data.totalPages || 1),
size: res.data.size || content.length,
number: res.data.currentPage || 0,
first: true,
last: true,
},
}
})
},
getDetail(id: number): Promise<ApiResponse<Order>> {
return request.get<ApiResponse<any>>(`/api/order/${id}`).then(async (res) => {
if (res.data.groupNo) {
const groupRes = await request.get<ApiResponse<any[]>>(`/api/order/group/${res.data.groupNo}`)
return {
...res,
data: aggregateOrders(groupRes.data)[0],
}
}
return {
...res,
data: normalizeOrder(res.data),
}
})
},
cancel(id: number): Promise<ApiResponse> {
return request.post(`/api/order/${id}/cancel`)
},
pay(id: number, paymentMethod: string): Promise<ApiResponse> {
return request.post(`/api/order/${id}/pay`, {paymentMethod})
},
ship(id: number): Promise<ApiResponse> {
return request.post(`/api/order/${id}/ship`)
},
updateStatus(id: number, status: number, remark?: string): Promise<ApiResponse> {
return request.put('/api/order/status', {orderId: id, status, remark})
},
confirm(id: number): Promise<ApiResponse> {
return request.post(`/api/order/${id}/confirm`)
},
delete(id: number): Promise<ApiResponse> {
return request.delete(`/api/order/${id}`)
},
getStatistics(): Promise<ApiResponse<{
total: number;
pending: number;
paid: number;
shipped: number;
completed: number;
cancelled: number
}>> {
return request.get<ApiResponse<any>>('/api/order/statistics').then((res) => ({
...res,
data: {
total: Number(res.data.totalOrders || 0),
pending: Number(res.data.pendingPaymentOrders || 0),
paid: Number(res.data.paidOrders || 0),
shipped: Number(res.data.shippedOrders || 0),
completed: Number(res.data.completedOrders || 0),
cancelled: Number(res.data.cancelledOrders || 0),
},
}))
},
}

View File

@@ -0,0 +1,57 @@
import {request} from '../request'
import type {ApiResponse, Product, PageParams, PageResponse} from '@/types/api'
import {normalizePage, normalizeProduct} from '@/utils/normalizers'
export const productApi = {
// 获取商品列表
getList(params?: PageParams & {
keyword?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
}): Promise<ApiResponse<PageResponse<Product>>> {
return request.get<ApiResponse<Record<string, any>>>('/api/product/list', {
page: params?.page ?? 0,
size: params?.size ?? 10,
keyword: params?.keyword,
category: params?.category,
minPrice: params?.minPrice,
maxPrice: params?.maxPrice,
sortBy: params?.sort || 'id',
sortDirection: params?.order || 'desc',
status: 1,
}).then((res) => ({
...res,
data: normalizePage(res.data, normalizeProduct),
}))
},
// 获取热门商品
getHot(limit = 8): Promise<ApiResponse<Product[]>> {
return request.get<ApiResponse<any[]>>('/api/product/hot', {limit}).then((res) => ({
...res,
data: Array.isArray(res.data) ? res.data.map((item) => normalizeProduct(item)) : [],
}))
},
// 获取商品详情
getDetail(id: number): Promise<ApiResponse<Product>> {
return request.get<ApiResponse<any>>(`/api/product/${id}`).then((res) => ({
...res,
data: normalizeProduct(res.data),
}))
},
// 搜索商品
search(keyword: string): Promise<ApiResponse<Product[]>> {
return this.getList({keyword, page: 0, size: 50}).then((res) => ({
...res,
data: res.data.content,
}))
},
// 获取商品分类
getCategories(): Promise<ApiResponse<string[]>> {
return request.get('/api/product/categories')
},
}

View File

@@ -0,0 +1,61 @@
import {request} from '../request'
import type {ApiResponse, OrderReturn} from '@/types/api'
import {normalizeOrderReturn} from '@/utils/normalizers'
export const returnApi = {
create(data: {
orderId: number;
reason: string;
description?: string;
images?: string
}): Promise<ApiResponse<OrderReturn>> {
return request.post('/api/return/create', data).then(normalizeResponse)
},
getByOrderId(orderId: number): Promise<ApiResponse<OrderReturn | null>> {
return request.get(`/api/return/order/${orderId}`)
.then((res: any) => ({
...res,
data: res.data ? normalizeOrderReturn(res.data) : null,
}))
},
getMyReturns(params?: { status?: number; page?: number; size?: number }): Promise<ApiResponse<any>> {
return request.get('/api/return/my', params)
},
ship(id: number, returnTracking: string): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/ship`, {returnTracking}).then(normalizeResponse)
},
cancel(id: number): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/cancel`).then(normalizeResponse)
},
adminReview(id: number, data: {
status: number;
rejectReason?: string;
adminRemark?: string
}): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/review`, data).then(normalizeResponse)
},
adminComplete(id: number, remark?: string): Promise<ApiResponse<OrderReturn>> {
return request.post(`/api/return/${id}/complete`, {remark}).then(normalizeResponse)
},
getAll(params?: { status?: number; page?: number; size?: number }): Promise<ApiResponse<any>> {
return request.get('/api/return/all', params)
},
getStatistics(): Promise<ApiResponse<any>> {
return request.get('/api/return/statistics')
},
}
function normalizeResponse(res: any): any {
return {
...res,
data: res.data ? normalizeOrderReturn(res.data) : res.data,
}
}

View File

@@ -0,0 +1,55 @@
import {request} from '../request'
import type {ApiResponse} from '@/types/api'
export interface ReviewItem {
id: number
productId: number
userId: number
orderId: number
username: string
productName?: string
productImage?: string
rating: number
content: string
adminReply?: string
createdAt: string
updatedAt?: string
}
export interface ReviewSummary {
averageRating: number
totalReviews: number
reviews: ReviewItem[]
}
export interface ReviewCheckResult {
reviewed: boolean
review?: ReviewItem
}
export const reviewApi = {
getProductReviews(productId: number): Promise<ApiResponse<ReviewSummary>> {
return request.get(`/api/review/product/${productId}`)
},
create(data: {
orderId: number;
productId: number;
rating: number;
content: string
}): Promise<ApiResponse<ReviewItem>> {
return request.post('/api/review', data)
},
checkReview(orderId: number, productId: number): Promise<ApiResponse<ReviewCheckResult>> {
return request.get('/api/review/check', {orderId, productId})
},
getMyReviews(): Promise<ApiResponse<ReviewItem[]>> {
return request.get('/api/review/my')
},
getOrderReviews(orderId: number): Promise<ApiResponse<ReviewItem[]>> {
return request.get(`/api/review/order/${orderId}`)
},
}

View File

@@ -0,0 +1,69 @@
import {request} from '../request'
import type {ApiResponse, User, LoginParams, RegisterParams} from '@/types/api'
import {normalizeUser} from '@/utils/normalizers'
export const userApi = {
// 登录
login(params: LoginParams): Promise<ApiResponse<{ token: string; user: User }>> {
return request.post<ApiResponse<{ token: string; user: any }>>('/api/user/login', params).then((res) => ({
...res,
data: {
token: res.data.token,
user: normalizeUser(res.data.user),
},
}))
},
// 注册
register(params: RegisterParams): Promise<ApiResponse<User>> {
return request.post<ApiResponse<any>>('/api/user/register', {
...params,
confirmPassword: params.password,
}).then((res) => ({
...res,
data: normalizeUser(res.data),
}))
},
// 退出登录
logout(): Promise<ApiResponse> {
return request.post('/api/user/logout')
},
// 获取用户信息
getInfo(): Promise<ApiResponse<User>> {
return request.get<ApiResponse<any>>('/api/user/current').then((res) => ({
...res,
data: normalizeUser(res.data),
}))
},
// 更新用户信息
updateInfo(data: Partial<User>): Promise<ApiResponse<User>> {
return request.put<ApiResponse<any>>('/api/user/update', {
email: data.email,
phone: data.phone,
avatar: data.avatar,
}).then((res) => ({
...res,
data: normalizeUser(res.data),
}))
},
// 修改密码
changePassword(data: { oldPassword: string; newPassword: string; confirmPassword?: string }): Promise<ApiResponse> {
return request.post('/api/user/change-password', {
...data,
confirmPassword: data.confirmPassword || data.newPassword,
})
},
getProfileStats(): Promise<ApiResponse<{
totalOrders: number;
totalAmount: number;
flashSaleSuccess: number;
favoriteCount: number
}>> {
return request.get('/api/user/profile-stats')
},
}

View File

@@ -0,0 +1,154 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios'
import {ElMessage, ElMessageBox} from 'element-plus'
import {useUserStore} from '@/stores/user'
import router from '@/router'
// 创建axios实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
withCredentials: true,
timeout: Number(import.meta.env.VITE_TIMEOUT) || 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
// 添加token
if (userStore.token) {
config.headers = config.headers || {}
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
if (typeof res?.success === 'boolean') {
if (!res.success) {
if (response.status === 401) {
ElMessage.error(res.message || '登录状态已失效')
} else {
ElMessage.error(res.message || '请求失败')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
}
// 自定义状态码处理
if (res.code !== 200 && res.code !== 0) {
// 业务错误
if (res.code === 401) {
// 未登录或token失效
ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
})
} else if (res.code === 403) {
// 无权限
ElMessage.error('无权限访问')
} else {
// 其他业务错误
ElMessage.error(res.message || '请求失败')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
// 提取后端返回的业务错误消息
const bizMessage = error.response?.data?.message
let displayMessage = ''
if (error.response) {
switch (error.response.status) {
case 400:
// 业务错误(如"该团组已满员"),只显示后端返回的消息
displayMessage = bizMessage || '请求参数错误'
break
case 401:
displayMessage = '未授权,请登录'
break
case 403:
displayMessage = '拒绝访问'
break
case 404:
displayMessage = '请求地址不存在'
break
case 429:
displayMessage = '请求过于频繁,请稍后再试'
break
case 500:
displayMessage = '服务器内部错误'
break
default:
displayMessage = bizMessage || '请求失败'
}
} else if (error.request) {
displayMessage = '网络错误,请检查网络连接'
} else {
displayMessage = '请求配置错误'
}
ElMessage.error(displayMessage)
// 用业务消息替换原始 axios error message避免组件 catch 显示 "Request failed with status code 400"
const rejectError = new Error(displayMessage)
;(rejectError as any)._handled = true
return Promise.reject(rejectError)
}
)
// 通用请求方法
export const request = {
get<T = any>(url: string, params?: any): Promise<T> {
return service.get(url, {params})
},
post<T = any>(url: string, data?: any): Promise<T> {
return service.post(url, data)
},
put<T = any>(url: string, data?: any): Promise<T> {
return service.put(url, data)
},
delete<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, {
...config,
data,
})
},
}
export default service

View File

@@ -0,0 +1,7 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#f8f9fa" stroke="#dee2e6" stroke-width="1"/>
<text x="50" y="35" font-family="Arial, sans-serif" font-size="12" fill="#6c757d" text-anchor="middle">商品</text>
<text x="50" y="50" font-family="Arial, sans-serif" font-size="12" fill="#6c757d" text-anchor="middle">图片</text>
<text x="50" y="70" font-family="Arial, sans-serif" font-size="10" fill="#adb5bd" text-anchor="middle">暂无图片
</text>
</svg>

After

Width:  |  Height:  |  Size: 533 B

View File

@@ -0,0 +1,81 @@
<template>
<div class="countdown-timer">
<template v-if="timeLeft > 0">
<el-icon class="countdown-icon mr-1">
<Clock/>
</el-icon>
<span class="time-block">{{ hours.toString().padStart(2, '0') }}</span>
<span class="separator">:</span>
<span class="time-block">{{ minutes.toString().padStart(2, '0') }}</span>
<span class="separator">:</span>
<span class="time-block">{{ seconds.toString().padStart(2, '0') }}</span>
</template>
<span v-else class="text-gray-400">已结束</span>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted, onUnmounted} from 'vue'
const props = defineProps<{
endTime: number
}>()
const emit = defineEmits<{
finish: []
}>()
const timeLeft = ref(0)
let timer: number | null = null
const hours = computed(() => Math.floor(timeLeft.value / 3600))
const minutes = computed(() => Math.floor((timeLeft.value % 3600) / 60))
const seconds = computed(() => timeLeft.value % 60)
const updateTime = () => {
const now = Date.now()
const remaining = Math.max(0, Math.floor((props.endTime - now) / 1000))
timeLeft.value = remaining
if (remaining === 0 && timer) {
clearInterval(timer)
timer = null
emit('finish')
}
}
onMounted(() => {
updateTime()
if (timeLeft.value > 0) {
timer = setInterval(updateTime, 1000)
}
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style lang="scss" scoped>
.countdown-timer {
@apply flex items-center justify-center text-lg font-mono;
.countdown-icon {
color: #5e5e58;
}
.time-block {
@apply px-2 py-1 rounded;
background: #fff;
color: #171715;
border: 1px solid #171715;
}
.separator {
@apply mx-1 font-bold;
color: #5e5e58;
}
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="activity-card card-shadow">
<div class="relative">
<SafeImage
:alt="data.productName"
:src="data.productImageUrl"
img-class="w-full h-48 object-cover"
wrapper-class="w-full h-48"
/>
<div class="absolute top-2 left-2">
<el-tag :type="statusType" effect="dark" size="small">
<el-icon class="mr-1">
<Lightning/>
</el-icon>
{{ statusText }}
</el-tag>
</div>
<div class="absolute top-2 right-2">
<span class="discount-badge">{{ discountPercent }}% OFF</span>
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
<div class="flex items-end mb-3">
<span class="flash-price">¥{{ data.flashPrice }}</span>
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.originalPrice }}</span>
</div>
<div class="mb-3">
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>剩余: {{ data.remainingStock }}</span>
<span>{{ stockPercent }}%</span>
</div>
<el-progress :color="progressColor" :percentage="stockPercent" :show-text="false" :stroke-width="6"/>
</div>
<div class="text-center mb-3">
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')"/>
<span v-else-if="data.status === 'UPCOMING'" class="text-sm text-gray-500">即将开始</span>
<span v-else class="text-sm text-gray-400">已结束</span>
</div>
<el-button :disabled="!canParticipate" :loading="loading" class="w-full" type="primary"
@click="handleParticipate">
<el-icon class="mr-1">
<Lightning/>
</el-icon>
{{ buttonText }}
</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import type {FlashSale} from '@/types/api'
import CountDown from './CountDown.vue'
import SafeImage from '@/components/common/SafeImage.vue'
const props = defineProps<{ data: FlashSale }>()
const emit = defineEmits<{ participate: [id: number]; refresh: [] }>()
const loading = ref(false)
const statusType = computed(() => {
switch (props.data.status) {
case 'UPCOMING':
return 'warning'
case 'ACTIVE':
return 'danger'
case 'ENDED':
return 'info'
case 'PAUSED':
return 'warning'
default:
return 'info'
}
})
const statusText = computed(() => {
switch (props.data.status) {
case 'UPCOMING':
return '即将开始'
case 'ACTIVE':
return '进行中'
case 'ENDED':
return '已结束'
case 'PAUSED':
return '已暂停'
default:
return '未知'
}
})
const discountPercent = computed(() => Math.round((1 - props.data.flashPrice / props.data.originalPrice) * 100))
const stockPercent = computed(() => props.data.flashStock === 0 ? 0 : Math.round(props.data.remainingStock / props.data.flashStock * 100))
const progressColor = computed(() => (stockPercent.value > 50 ? '#171715' : stockPercent.value > 20 ? '#5e5e58' : '#9f9f99'))
const endTime = computed(() => new Date(props.data.endTime).getTime())
const canParticipate = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
const buttonText = computed(() => {
if (props.data.status === 'UPCOMING') return '即将开始'
if (props.data.status === 'ENDED') return '已结束'
if (props.data.remainingStock === 0) return '已售罄'
return '立即抢购'
})
const handleParticipate = async () => {
if (!canParticipate.value) return
loading.value = true
emit('participate', props.data.id)
setTimeout(() => {
loading.value = false
}, 1000)
}
</script>
<style lang="scss" scoped>
.activity-card {
@apply bg-white rounded-2xl overflow-hidden;
background: #fffaf2;
transition: all 0.3s;
&:hover {
transform: translateY(-4px);
}
}
.flash-price {
@apply text-2xl font-bold;
color: #171715;
}
.discount-badge {
@apply px-2 py-1 text-xs font-bold rounded;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="group-buying-card card-shadow" @click="$router.push(`/groupbuying/${data.id}`)">
<div class="relative cursor-pointer">
<SafeImage
:alt="data.productName"
:src="data.productImageUrl"
img-class="w-full h-48 object-cover"
wrapper-class="w-full h-48"
/>
<div class="absolute top-2 left-2">
<el-tag :type="statusType" effect="dark" size="small">
<el-icon class="mr-1">
<Connection/>
</el-icon>
{{ statusText }}
</el-tag>
</div>
<div class="absolute top-2 right-2">
<span class="discount-badge"> ¥{{ data.discount }}</span>
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
<div class="flex items-end mb-2">
<span class="group-price">¥{{ data.groupPrice }}</span>
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.productPrice }}</span>
</div>
<div class="flex items-center text-sm text-gray-500 mb-2">
<el-icon class="mr-1">
<User/>
</el-icon>
<span>{{ data.requiredMembers }}人团</span>
<span class="mx-2">|</span>
<span>剩余 {{ data.remainingStock }} </span>
</div>
<div class="mb-3">
<el-progress :color="progressColor" :percentage="stockPercent" :show-text="false" :stroke-width="6"/>
</div>
<div class="flex items-center justify-between text-sm text-gray-500 mb-3">
<span v-if="data.activeGroupCount > 0">{{ data.activeGroupCount }} 个团进行中</span>
<span v-else>暂无进行中的团</span>
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')"/>
</div>
<el-button :disabled="!canJoin" class="w-full" type="primary" @click.stop="handleJoin">
<el-icon class="mr-1">
<Connection/>
</el-icon>
{{ buttonText }}
</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import type {GroupBuying} from '@/types/api'
import CountDown from './CountDown.vue'
import SafeImage from '@/components/common/SafeImage.vue'
const props = defineProps<{ data: GroupBuying }>()
const emit = defineEmits<{ join: [id: number]; refresh: [] }>()
const statusType = computed(() => {
switch (props.data.status) {
case 'UPCOMING':
return 'warning'
case 'ACTIVE':
return 'success'
case 'ENDED':
return 'info'
default:
return 'info'
}
})
const statusText = computed(() => {
switch (props.data.status) {
case 'DRAFT':
return '草稿'
case 'UPCOMING':
return '即将开始'
case 'ACTIVE':
return '拼团中'
case 'ENDED':
return '已结束'
default:
return '未知'
}
})
const stockPercent = computed(() => props.data.totalStock === 0 ? 0 : Math.round(props.data.remainingStock / props.data.totalStock * 100))
const progressColor = computed(() => (stockPercent.value > 50 ? '#171715' : stockPercent.value > 20 ? '#5e5e58' : '#9f9f99'))
const endTime = computed(() => new Date(props.data.endTime).getTime())
const canJoin = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
const buttonText = computed(() => {
if (props.data.status === 'UPCOMING') return '即将开始'
if (props.data.status === 'ENDED') return '已结束'
if (props.data.remainingStock === 0) return '已售罄'
return '去拼团'
})
const handleJoin = () => {
if (!canJoin.value) return
emit('join', props.data.id)
}
</script>
<style lang="scss" scoped>
.group-buying-card {
@apply bg-white rounded-2xl overflow-hidden;
background: #fffaf2;
transition: all 0.3s;
&:hover {
transform: translateY(-4px);
}
}
.group-price {
@apply text-2xl font-bold;
color: #171715;
}
.discount-badge {
@apply px-2 py-1 text-xs font-bold rounded;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="group-member-list">
<div class="flex items-center gap-3 flex-wrap">
<div v-for="member in members" :key="member.userId" :title="member.username" class="member-avatar">
<el-avatar :size="40" :src="member.avatar">
{{ member.username ? member.username[0] : '?' }}
</el-avatar>
<span class="member-name">{{ member.username }}</span>
<el-tag v-if="member.userId === leaderUserId" class="leader-tag" size="small" type="warning">团长</el-tag>
</div>
<div v-for="i in emptySlots" :key="'empty-' + i" class="member-avatar empty">
<div class="empty-slot">
<el-icon :size="20">
<Plus/>
</el-icon>
</div>
<span class="member-name text-gray-400">等待加入</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import type {GroupBuyingMember} from '@/types/api'
const props = defineProps<{
members: GroupBuyingMember[]
requiredMembers: number
leaderUserId?: number
}>()
const emptySlots = computed(() => Math.max(0, props.requiredMembers - props.members.length))
</script>
<style lang="scss" scoped>
.member-avatar {
@apply flex flex-col items-center gap-1;
.member-name {
@apply text-xs text-gray-600 truncate;
max-width: 60px;
}
.leader-tag {
@apply mt-0.5;
}
}
.empty-slot {
@apply w-10 h-10 rounded-full border-2 border-dashed border-gray-300 flex items-center justify-center text-gray-400;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="product-card card-shadow">
<div class="relative overflow-hidden h-48">
<SafeImage
:alt="data.name"
:src="data.imageUrl"
img-class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
wrapper-class="w-full h-full"
/>
<div v-if="data.stock <= 10" class="absolute top-2 right-2">
<el-tag size="small" type="warning">
仅剩 {{ data.stock }}
</el-tag>
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-base mb-2 truncate">{{ data.name }}</h3>
<p class="text-sm text-gray-500 mb-3 line-clamp-2">
{{ data.description || '暂无描述' }}
</p>
<div class="flex justify-between items-center mb-3">
<span class="price">¥{{ data.price }}</span>
<span class="text-sm text-gray-400">库存: {{ data.stock }}</span>
</div>
<div class="flex gap-2">
<el-button :disabled="data.stock === 0" class="flex-1" size="small" type="primary" @click="handleAddToCart">
<el-icon class="mr-1">
<ShoppingCart/>
</el-icon>
加入购物车
</el-button>
<el-button size="small" @click="handleViewDetail">
<el-icon>
<View/>
</el-icon>
</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {useRouter} from 'vue-router'
import type {Product} from '@/types/api'
import SafeImage from '@/components/common/SafeImage.vue'
const props = defineProps<{ data: Product }>()
const emit = defineEmits<{ addToCart: [id: number] }>()
const router = useRouter()
const handleAddToCart = () => {
if (props.data.stock > 0) {
emit('addToCart', props.data.id)
}
}
const handleViewDetail = () => {
router.push(`/product/${props.data.id}`)
}
</script>
<style lang="scss" scoped>
.product-card {
@apply bg-white rounded-2xl overflow-hidden;
background: #fffaf2;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
}
}
.price {
@apply text-xl font-bold;
color: #171715;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<el-dialog
:model-value="visible"
title="申请退货"
width="520px"
@update:model-value="$emit('update:visible', $event)"
>
<el-form :model="form" label-width="80px">
<el-form-item label="退款金额">
<span class="text-lg font-bold text-red-500">&yen;{{ refundAmount }}</span>
</el-form-item>
<el-form-item label="退货原因" required>
<el-select v-model="form.reason" placeholder="请选择退货原因" style="width: 100%">
<el-option label="质量问题" value="质量问题"/>
<el-option label="商品与描述不符" value="商品与描述不符"/>
<el-option label="发错商品" value="发错商品"/>
<el-option label="不想要了" value="不想要了"/>
<el-option label="其他" value="其他"/>
</el-select>
</el-form-item>
<el-form-item label="详细描述">
<el-input
v-model="form.description"
:rows="4"
maxlength="500"
placeholder="请描述退货原因(选填)"
show-word-limit
type="textarea"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button
:disabled="!form.reason"
:loading="submitting"
type="primary"
@click="handleSubmit"
>
提交申请
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {ref, reactive, watch} from 'vue'
import {ElMessage} from 'element-plus'
import {returnApi} from '@/api/modules/return'
const props = defineProps<{
visible: boolean
orderId: number
refundAmount: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const submitting = ref(false)
const form = reactive({
reason: '',
description: '',
})
const handleSubmit = async () => {
if (!form.reason) {
ElMessage.warning('请选择退货原因')
return
}
submitting.value = true
try {
const res = await returnApi.create({
orderId: props.orderId,
reason: form.reason,
description: form.description || undefined,
})
if (res.success) {
ElMessage.success('退货申请已提交')
emit('success')
emit('update:visible', false)
} else {
ElMessage.error(res.message || '申请失败')
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || '申请失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
watch(() => props.visible, (val) => {
if (val) {
form.reason = ''
form.description = ''
}
})
</script>

View File

@@ -0,0 +1,82 @@
<template>
<el-dialog
:model-value="visible"
title="填写退货物流"
width="460px"
@update:model-value="$emit('update:visible', $event)"
>
<el-form :model="form" label-width="90px">
<el-form-item label="物流单号" required>
<el-input
v-model="form.returnTracking"
clearable
maxlength="100"
placeholder="请输入退货物流单号"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button
:disabled="!form.returnTracking.trim()"
:loading="submitting"
type="primary"
@click="handleSubmit"
>
提交
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {ref, reactive, watch} from 'vue'
import {ElMessage} from 'element-plus'
import {returnApi} from '@/api/modules/return'
const props = defineProps<{
visible: boolean
returnId: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const submitting = ref(false)
const form = reactive({
returnTracking: '',
})
const handleSubmit = async () => {
if (!form.returnTracking.trim()) {
ElMessage.warning('请输入物流单号')
return
}
submitting.value = true
try {
const res = await returnApi.ship(props.returnId, form.returnTracking.trim())
if (res.success) {
ElMessage.success('物流信息已提交')
emit('success')
emit('update:visible', false)
} else {
ElMessage.error(res.message || '提交失败')
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || '提交失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
watch(() => props.visible, (val) => {
if (val) {
form.returnTracking = ''
}
})
</script>

View File

@@ -0,0 +1,197 @@
<template>
<el-dialog
:model-value="visible"
title="商品评价"
width="640px"
@update:model-value="$emit('update:visible', $event)"
>
<div v-if="checkLoading" class="text-center py-8">
<el-icon :size="32" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载评价状态...</p>
</div>
<div v-else class="space-y-6">
<div v-if="reviewableItems.length === 0 && reviewedItems.length === 0" class="text-center py-8">
<el-empty description="暂无可评价商品"/>
</div>
<!-- 待评价商品 -->
<div v-for="item in reviewableItems" :key="item.productId" class="border rounded-lg p-4">
<div class="flex gap-4 mb-4">
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-16 h-16 object-cover rounded"
wrapper-class="w-16 h-16 rounded"/>
<div class="flex-1">
<h4 class="font-semibold">{{ item.productName }}</h4>
<div class="text-sm text-gray-500">¥{{ item.price }} × {{ item.quantity }}</div>
</div>
</div>
<div class="mb-3">
<label class="block text-sm text-gray-600 mb-1">评分</label>
<el-rate v-model="item.rating" :texts="['很差', '较差', '一般', '满意', '非常满意']" show-text/>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">评价内容</label>
<el-input
v-model="item.content"
:rows="3"
maxlength="500"
placeholder="分享一下你的使用感受吧"
show-word-limit
type="textarea"
/>
</div>
</div>
<!-- 已评价商品 -->
<div v-for="item in reviewedItems" :key="'reviewed-' + item.productId" class="border rounded-lg p-4 bg-gray-50">
<div class="flex gap-4">
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-16 h-16 object-cover rounded"
wrapper-class="w-16 h-16 rounded"/>
<div class="flex-1">
<div class="flex items-center justify-between mb-1">
<h4 class="font-semibold">{{ item.productName }}</h4>
<el-tag size="small" type="success">已评价</el-tag>
</div>
<el-rate :model-value="item.existingReview!.rating" disabled/>
<p class="text-sm text-gray-600 mt-1">{{ item.existingReview!.content }}</p>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="$emit('update:visible', false)">关闭</el-button>
<el-button
v-if="reviewableItems.length > 0"
:disabled="!canSubmit"
:loading="submitting"
type="primary"
@click="handleSubmit"
>
提交评价
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {ElMessage} from 'element-plus'
import {reviewApi} from '@/api/modules/review'
import type {ReviewItem} from '@/api/modules/review'
import type {OrderItem} from '@/types/api'
import SafeImage from '@/components/common/SafeImage.vue'
interface ReviewableItem extends OrderItem {
rating: number
content: string
reviewed: boolean
existingReview?: ReviewItem
}
const props = defineProps<{
visible: boolean
orderId: number
orderItems: OrderItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const checkLoading = ref(false)
const submitting = ref(false)
const items = ref<ReviewableItem[]>([])
const reviewableItems = computed(() => items.value.filter(i => !i.reviewed))
const reviewedItems = computed(() => items.value.filter(i => i.reviewed))
const canSubmit = computed(() => reviewableItems.value.some(i => i.content.trim()))
const loadReviewStatus = async () => {
if (!props.orderId || !props.orderItems.length) {
items.value = []
return
}
checkLoading.value = true
try {
const list: ReviewableItem[] = props.orderItems.map(item => ({
...item,
rating: 5,
content: '',
reviewed: false,
existingReview: undefined,
}))
const checks = await Promise.all(
list.map(item => reviewApi.checkReview(props.orderId, item.productId).catch(() => null))
)
checks.forEach((res, index) => {
if (res?.success && res.data.reviewed) {
list[index].reviewed = true
list[index].existingReview = res.data.review
}
})
items.value = list
} finally {
checkLoading.value = false
}
}
const handleSubmit = async () => {
const toSubmit = reviewableItems.value.filter(i => i.content.trim())
if (toSubmit.length === 0) {
ElMessage.warning('请至少填写一条评价内容')
return
}
submitting.value = true
let successCount = 0
try {
for (const item of toSubmit) {
try {
await reviewApi.create({
orderId: props.orderId,
productId: item.productId,
rating: item.rating,
content: item.content.trim(),
})
item.reviewed = true
item.existingReview = {rating: item.rating, content: item.content} as ReviewItem
successCount++
} catch (error: any) {
const respData = error?.response?.data
const msg = respData?.message || error?.message || '提交失败'
ElMessage.error(`${item.productName}: ${msg}`)
}
}
if (successCount > 0) {
ElMessage.success(`成功提交 ${successCount} 条评价`)
emit('success')
if (reviewableItems.value.length === 0) {
emit('update:visible', false)
}
}
} finally {
submitting.value = false
}
}
watch(() => props.visible, (val) => {
if (val) loadReviewStatus()
if (!val) items.value = []
})
watch(
() => [props.orderId, props.orderItems],
() => {
if (props.visible) loadReviewStatus()
},
{immediate: true}
)
</script>

View File

@@ -0,0 +1,101 @@
<template>
<footer class="app-footer">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- 关于我们 -->
<div>
<h3 class="text-lg font-semibold mb-4">关于我们</h3>
<p class="text-gray-600 text-sm">
社区生鲜团购平台提供商品浏览拼团下单和订单管理服务
</p>
</div>
<!-- 快速链接 -->
<div>
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
<ul class="space-y-2">
<li>
<router-link class="footer-link" to="/">
首页
</router-link>
</li>
<li>
<router-link class="footer-link" to="/groupbuying">
拼团活动
</router-link>
</li>
<li>
<router-link class="footer-link" to="/products">
商品列表
</router-link>
</li>
</ul>
</div>
<!-- 技术栈 -->
<div>
<h3 class="text-lg font-semibold mb-4">技术栈</h3>
<div class="flex flex-wrap gap-2">
<span class="tech-tag">Vue 3</span>
<span class="tech-tag">Vite</span>
<span class="tech-tag">TypeScript</span>
<span class="tech-tag">Element Plus</span>
<span class="tech-tag">Pinia</span>
<span class="tech-tag">TailwindCSS</span>
</div>
</div>
<!-- 联系我们 -->
<div>
<h3 class="text-lg font-semibold mb-4">联系我们</h3>
<div class="space-y-2 text-gray-600">
<p class="flex items-center">
<el-icon class="mr-2">
<Message/>
</el-icon>
service@freshgroup.com
</p>
<p class="flex items-center">
<el-icon class="mr-2">
<Phone/>
</el-icon>
400-123-4567
</p>
</div>
</div>
</div>
<div class="border-t mt-8 pt-8 text-center text-gray-500 text-sm">
<p>&copy; 社区生鲜团购平台. All rights reserved.</p>
</div>
</div>
</footer>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped>
.app-footer {
background: rgba(255, 255, 255, 0.92);
border-top: 1px solid #d8cebf;
margin-top: auto;
}
.footer-link {
color: #5e5e58;
&:hover {
color: #171715;
}
}
.tech-tag {
padding: 4px 10px;
background-color: #fffaf2;
border: 1px solid #d8cebf;
border-radius: 999px;
font-size: 12px;
color: #5c5346;
}
</style>

View File

@@ -0,0 +1,412 @@
<template>
<header class="app-header">
<div class="container mx-auto px-4">
<nav class="header-nav">
<div class="header-brand">
<router-link class="brand-link" to="/">
<el-icon :size="24" class="brand-icon">
<Lightning/>
</el-icon>
<span class="brand-title">社区生鲜团购系统</span>
</router-link>
</div>
<div class="header-links hidden xl:flex items-center">
<router-link class="nav-link" to="/">
<el-icon>
<HomeFilled/>
</el-icon>
首页
</router-link>
<el-dropdown trigger="hover" @command="handleCategoryCommand">
<router-link class="nav-link" to="/products">
<el-icon>
<ShoppingBag/>
</el-icon>
商品列表
<el-icon :size="12" class="ml-1">
<ArrowDown/>
</el-icon>
</router-link>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="">全部商品</el-dropdown-item>
<el-dropdown-item
v-for="cat in categories"
:key="cat"
:command="cat"
>
{{ cat }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<router-link class="nav-link" to="/groupbuying">
<el-icon>
<Connection/>
</el-icon>
拼团
</router-link>
</div>
<div class="header-actions">
<el-dropdown class="header-menu xl:hidden" trigger="click" @command="handleMainNavCommand">
<button aria-label="打开导航菜单" class="menu-trigger" type="button">
<el-icon :size="18">
<Menu/>
</el-icon>
<span class="hidden sm:inline">菜单</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="/">首页</el-dropdown-item>
<el-dropdown-item command="/products">商品列表</el-dropdown-item>
<el-dropdown-item command="/groupbuying">拼团</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="header-search hidden lg:block">
<SearchComponent/>
</div>
<NotificationCenter v-if="userStore.isLoggedIn && !userStore.isAdmin"/>
<router-link v-if="!userStore.isAdmin" class="cart-link relative" to="/cart">
<el-badge :hidden="cartCount === 0" :value="cartCount" class="cart-badge">
<el-icon :size="20">
<ShoppingCart/>
</el-icon>
</el-badge>
</router-link>
<template v-if="userStore.isLoggedIn">
<el-dropdown trigger="click">
<div class="user-trigger flex items-center space-x-2 cursor-pointer">
<el-avatar :size="32" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
</el-avatar>
<span class="hidden md:inline">{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/profile')">
<el-icon>
<User/>
</el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/orders')">
<el-icon>
<List/>
</el-icon>
我的订单
</el-dropdown-item>
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/favorites')">
<el-icon>
<Star/>
</el-icon>
我的收藏
</el-dropdown-item>
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/reviews')">
<el-icon>
<ChatDotRound/>
</el-icon>
我的评价
</el-dropdown-item>
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/notifications')">
<el-icon>
<Bell/>
</el-icon>
消息通知
</el-dropdown-item>
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
<el-icon>
<Setting/>
</el-icon>
管理后台
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon>
<SwitchButton/>
</el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<el-button text @click="router.push('/login')">登录</el-button>
<el-button type="primary" @click="router.push('/register')">注册</el-button>
</template>
</div>
</nav>
</div>
</header>
</template>
<script lang="ts" setup>
import {ref, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {useUserStore} from '@/stores/user'
import {useCartStore} from '@/stores/cart'
import {productApi} from '@/api/modules/product'
import NotificationCenter from './NotificationCenter.vue'
import SearchComponent from './SearchComponent.vue'
import {ElMessageBox} from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const cartStore = useCartStore()
const cartCount = ref(0)
const categories = ref<string[]>([])
// 加载分类
const loadCategories = async () => {
try {
const res = await productApi.getCategories()
if (res.success) {
categories.value = res.data
}
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 分类下拉菜单点击
const handleCategoryCommand = (category: string) => {
if (category) {
router.push({path: '/products', query: {category}})
} else {
router.push('/products')
}
}
const handleMainNavCommand = (path: string) => {
if (path) {
router.push(path)
}
}
// 退出登录
const handleLogout = async () => {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await userStore.logout()
}
// 更新购物车数量
const updateCartCount = async () => {
if (userStore.isLoggedIn && !userStore.isAdmin) {
cartCount.value = await cartStore.getCartCount()
}
}
onMounted(() => {
loadCategories()
updateCartCount()
})
</script>
<style lang="scss" scoped>
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: rgba(255, 250, 242, 0.92);
backdrop-filter: none;
border-bottom: 1px solid #d8cebf;
}
.header-nav {
min-height: var(--app-header-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.header-brand,
.header-actions {
display: flex;
align-items: center;
min-width: 0;
}
.header-actions {
gap: 12px;
flex-shrink: 0;
:deep(.el-button) {
font-size: 14px;
}
}
.header-links {
gap: 28px;
flex: 1;
justify-content: center;
min-width: 0;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
color: #171715;
min-width: 0;
}
.brand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 12px;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
}
.brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
}
.brand-tag {
padding: 5px 10px;
border-radius: 999px;
border: 1px solid #d8cebf;
background: #fffaf2;
color: #5c5346;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
white-space: nowrap;
}
.nav-link {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 2px;
font-size: 14px;
color: #5e5e58;
text-decoration: none;
transition: color 0.25s ease;
position: relative;
&:hover {
color: #171715;
}
&.router-link-active {
color: #171715;
font-weight: 600;
}
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 1px;
background: #171715;
transform: scaleX(0);
transform-origin: center;
transition: transform 0.25s ease;
}
&:hover::after,
&.router-link-active::after {
transform: scaleX(1);
}
}
.menu-trigger,
.user-trigger {
display: flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid #d8cebf;
background: #fffaf2;
color: #2b2b27;
font-size: 14px;
}
.menu-trigger {
gap: 6px;
cursor: pointer;
}
.cart-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 6px;
color: #2b2b27;
background: transparent;
border: 1px solid transparent;
border-radius: 0;
box-shadow: none;
}
.header-search {
:deep(.search-input) {
width: clamp(180px, 18vw, 280px);
}
}
.cart-badge {
:deep(.el-badge__content) {
background-color: #fffaf2;
color: #171715;
border: 1px solid #171715;
}
}
@media (max-width: 1279px) {
.brand-tag {
display: none;
}
}
@media (max-width: 767px) {
.header-nav {
gap: 12px;
}
.brand-title {
font-size: 16px;
}
.header-actions {
gap: 8px;
}
.cart-link,
.user-trigger,
.menu-trigger {
min-height: 36px;
padding: 0 10px;
}
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div class="image-upload">
<el-upload
:accept="accept"
:action="uploadUrl"
:before-upload="beforeUpload"
:class="{ 'hide-upload': fileList.length >= limit }"
:file-list="fileList"
:headers="headers"
:limit="limit"
:multiple="multiple"
:on-error="handleError"
:on-exceed="handleExceed"
:on-preview="handlePreview"
:on-remove="handleRemove"
:on-success="handleSuccess"
:with-credentials="true"
list-type="picture-card"
>
<template #default>
<el-icon class="upload-icon">
<Plus/>
</el-icon>
</template>
<template #file="{ file }">
<div class="upload-file-item">
<img :alt="file.name" :src="file.url" class="upload-image"/>
<span class="upload-actions">
<span class="upload-action" @click="handlePreview(file)">
<el-icon><ZoomIn/></el-icon>
</span>
<span class="upload-action" @click="handleDownload(file)">
<el-icon><Download/></el-icon>
</span>
<span class="upload-action" @click="handleRemove(file)">
<el-icon><Delete/></el-icon>
</span>
</span>
<!-- 上传进度 -->
<el-progress
v-if="file.status === 'uploading'"
:percentage="file.percentage"
class="upload-progress"
type="circle"
/>
</div>
</template>
</el-upload>
<!-- 图片预览 -->
<el-dialog
v-model="previewVisible"
append-to-body
title="图片预览"
width="800px"
>
<div class="preview-container">
<img :alt="previewName" :src="previewUrl" class="preview-image"/>
<div class="preview-info">
<p><strong>文件名</strong>{{ previewName }}</p>
<p><strong>文件大小</strong>{{ formatFileSize(previewSize) }}</p>
</div>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {ElMessage} from 'element-plus'
import {resolveImageUrl} from '@/utils/image'
import {useUserStore} from '@/stores/user'
import type {UploadFile, UploadRawFile} from 'element-plus'
interface Props {
modelValue?: string | string[]
limit?: number
multiple?: boolean
accept?: string
maxSize?: number // MB
aspectRatio?: number // 宽高比
action?: string
}
const props = withDefaults(defineProps<Props>(), {
limit: 1,
multiple: false,
accept: 'image/jpeg,image/jpg,image/png,image/gif,image/webp',
maxSize: 5, // 5MB
action: '/api/admin/products/upload-image',
})
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
change: [value: string | string[]]
}>()
interface UploadFileWithRawUrl extends UploadFile {
rawUrl?: string
}
const userStore = useUserStore()
// 上传相关
const uploadUrl = computed(() => `${import.meta.env.VITE_API_BASE_URL || ''}${props.action}`)
const headers = computed(() => ({
Authorization: `Bearer ${userStore.token}`
}))
const fileList = ref<UploadFileWithRawUrl[]>([])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewName = ref('')
const previewSize = ref(0)
const buildFileItem = (rawUrl: string, index: number): UploadFileWithRawUrl => ({
name: props.multiple ? `image-${index}` : 'image',
url: resolveImageUrl(rawUrl),
status: 'success',
uid: `${Date.now()}-${index}`,
rawUrl,
})
const getRawUrl = (file: UploadFile) => {
const currentFile = file as UploadFileWithRawUrl
return currentFile.rawUrl?.trim() || file.url?.trim() || ''
}
// 初始化文件列表
watch(
() => props.modelValue,
(val) => {
const nextFiles = Array.isArray(val)
? val
.map((url) => String(url || '').trim())
.filter(Boolean)
.map((url, index) => buildFileItem(url, index))
: val && String(val).trim()
? [buildFileItem(String(val).trim(), 0)]
: []
const currentRawUrls = fileList.value.map((file) => getRawUrl(file))
const nextRawUrls = nextFiles.map((file) => getRawUrl(file))
if (JSON.stringify(currentRawUrls) !== JSON.stringify(nextRawUrls)) {
fileList.value = nextFiles
}
},
{immediate: true}
)
// 格式化文件大小
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else {
return (size / 1024 / 1024).toFixed(2) + ' MB'
}
}
// 上传前验证
const beforeUpload = (rawFile: UploadRawFile) => {
// 验证文件类型
const acceptTypes = props.accept.split(',').map(t => t.trim())
if (!acceptTypes.includes(rawFile.type)) {
ElMessage.error('只能上传图片文件!')
return false
}
// 验证文件大小
if (rawFile.size / 1024 / 1024 > props.maxSize) {
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB`)
return false
}
// 验证图片宽高比(可选)
if (props.aspectRatio) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const ratio = img.width / img.height
const targetRatio = props.aspectRatio!
const tolerance = 0.1 // 允许10%的误差
if (Math.abs(ratio - targetRatio) > tolerance) {
ElMessage.error(`图片宽高比不符合要求(需要 ${targetRatio}:1`)
reject(false)
} else {
resolve(true)
}
}
img.src = e.target?.result as string
}
reader.readAsDataURL(rawFile)
})
}
return true
}
// 上传成功
const handleSuccess = (response: any, file: UploadFile) => {
if (response.success) {
const rawImageUrl = String(response.imageUrl || response.data?.imageUrl || response.data?.url || '').trim()
if (!rawImageUrl) {
handleRemove(file)
ElMessage.error('上传成功但未返回图片地址')
return
}
const uploadedFile = file as UploadFileWithRawUrl
uploadedFile.status = 'success'
uploadedFile.url = resolveImageUrl(rawImageUrl)
uploadedFile.rawUrl = rawImageUrl
if (props.limit === 1 && !props.multiple) {
fileList.value = [uploadedFile]
} else {
const nextFiles = [...fileList.value]
const index = nextFiles.findIndex((item) => item.uid === uploadedFile.uid)
if (index > -1) {
nextFiles[index] = uploadedFile
} else {
nextFiles.push(uploadedFile)
}
fileList.value = nextFiles
}
updateValue()
ElMessage.success('上传成功')
} else {
handleRemove(file)
ElMessage.error(response.message || '上传失败')
}
}
// 上传失败
const handleError = (error: Error) => {
ElMessage.error('上传失败,请重试')
console.error('Upload error:', error)
}
// 超出数量限制
const handleExceed = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片`)
}
// 预览图片
const handlePreview = (file: UploadFile) => {
previewUrl.value = file.url!
previewName.value = file.name
previewSize.value = file.size || 0
previewVisible.value = true
}
// 下载图片
const handleDownload = (file: UploadFile) => {
const link = document.createElement('a')
link.href = file.url!
link.download = file.name
link.click()
}
// 删除图片
const handleRemove = (file: UploadFile) => {
fileList.value = fileList.value.filter(f => f.uid !== file.uid)
updateValue()
}
// 更新值
const updateValue = () => {
const urls = fileList.value
.filter(f => f.status === 'success')
.map(f => getRawUrl(f))
.filter(Boolean)
const value = props.multiple ? urls : urls[0] || ''
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style lang="scss" scoped>
.image-upload {
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: 120px;
height: 120px;
}
}
:deep(.el-upload--picture-card) {
width: 120px;
height: 120px;
.upload-icon {
font-size: 28px;
color: #8c939d;
}
}
&.hide-upload {
:deep(.el-upload--picture-card) {
display: none;
}
}
.upload-file-item {
position: relative;
width: 100%;
height: 100%;
.upload-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-actions {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
cursor: default;
text-align: center;
color: #fff;
opacity: 0;
font-size: 20px;
background-color: rgba(0, 0, 0, 0.5);
transition: opacity 0.3s;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
.upload-action {
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
&:hover .upload-actions {
opacity: 1;
}
.upload-progress {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}
.preview-container {
.preview-image {
width: 100%;
max-height: 500px;
object-fit: contain;
}
.preview-info {
margin-top: 20px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
p {
margin: 5px 0;
font-size: 14px;
}
}
}
</style>

View File

@@ -0,0 +1,411 @@
<template>
<div class="notification-center">
<el-popover
:visible="visible"
:width="380"
placement="bottom-end"
trigger="click"
@update:visible="val => visible = val"
>
<template #reference>
<div class="notification-trigger">
<el-badge :hidden="unreadCount === 0" :value="unreadCount">
<el-icon :size="20">
<Bell/>
</el-icon>
</el-badge>
</div>
</template>
<div class="notification-content">
<!-- 标题栏 -->
<div class="notification-header">
<span class="title">消息通知</span>
<div class="actions">
<el-button size="small" text @click="markAllAsRead">
全部已读
</el-button>
<el-button size="small" text @click="clearAll">
清空
</el-button>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="notification-tabs">
<el-tab-pane label="全部" name="all">
<div class="notification-list">
<div
v-for="item in allNotifications"
:key="item.id"
:class="{ unread: !item.read }"
class="notification-item"
@click="handleClick(item)"
>
<el-icon :class="getIconClass(item.type)" :size="16">
<component :is="getIcon(item.type)"/>
</el-icon>
<div class="content">
<div class="title">{{ item.title }}</div>
<div class="message">{{ item.message }}</div>
<div class="time">{{ formatTime(item.createdAt) }}</div>
</div>
<el-button
v-if="!item.read"
size="small"
text
@click.stop="markAsRead(item.id)"
>
标记已读
</el-button>
</div>
<el-empty v-if="allNotifications.length === 0" description="暂无消息"/>
</div>
</el-tab-pane>
<el-tab-pane label="限时" name="flashsale">
<div class="notification-list">
<div
v-for="item in flashsaleNotifications"
:key="item.id"
:class="{ unread: !item.read }"
class="notification-item"
@click="handleClick(item)"
>
<el-icon :size="16" class="notification-icon">
<Lightning/>
</el-icon>
<div class="content">
<div class="title">{{ item.title }}</div>
<div class="message">{{ item.message }}</div>
<div class="time">{{ formatTime(item.createdAt) }}</div>
</div>
</div>
<el-empty v-if="flashsaleNotifications.length === 0" description="暂无限时消息"/>
</div>
</el-tab-pane>
<el-tab-pane label="订单" name="order">
<div class="notification-list">
<div
v-for="item in orderNotifications"
:key="item.id"
:class="{ unread: !item.read }"
class="notification-item"
@click="handleClick(item)"
>
<el-icon :size="16" class="notification-icon">
<List/>
</el-icon>
<div class="content">
<div class="title">{{ item.title }}</div>
<div class="message">{{ item.message }}</div>
<div class="time">{{ formatTime(item.createdAt) }}</div>
</div>
</div>
<el-empty v-if="orderNotifications.length === 0" description="暂无订单消息"/>
</div>
</el-tab-pane>
<el-tab-pane label="系统" name="system">
<div class="notification-list">
<div
v-for="item in systemNotifications"
:key="item.id"
:class="{ unread: !item.read }"
class="notification-item"
@click="handleClick(item)"
>
<el-icon :size="16" class="text-gray-500">
<InfoFilled/>
</el-icon>
<div class="content">
<div class="title">{{ item.title }}</div>
<div class="message">{{ item.message }}</div>
<div class="time">{{ formatTime(item.createdAt) }}</div>
</div>
</div>
<el-empty v-if="systemNotifications.length === 0" description="暂无系统消息"/>
</div>
</el-tab-pane>
</el-tabs>
<!-- 底部 -->
<div class="notification-footer">
<router-link class="view-all" to="/notifications">
查看全部消息
</router-link>
</div>
</div>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted, onUnmounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {notificationApi} from '@/api/modules/notification'
import type {NotificationItem} from '@/api/modules/notification'
import {useUserStore} from '@/stores/user'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const router = useRouter()
const userStore = useUserStore()
const visible = ref(false)
const activeTab = ref('all')
const notifications = ref<NotificationItem[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
// 计算属性
const unreadCount = computed(() =>
notifications.value.filter(n => !n.read).length
)
const allNotifications = computed(() => notifications.value)
const flashsaleNotifications = computed(() =>
notifications.value.filter(n => n.type === 'flashsale')
)
const orderNotifications = computed(() =>
notifications.value.filter(n => n.type === 'order')
)
const systemNotifications = computed(() =>
notifications.value.filter(n => n.type === 'system')
)
// 从后端加载通知
const fetchNotifications = async () => {
if (!userStore.isLoggedIn) return
try {
const res = await notificationApi.getList()
if (res?.success) {
notifications.value = res.data || []
}
} catch {
// 静默失败,不影响用户体验
}
}
// 格式化时间
const formatTime = (timestamp: number | string) => {
return dayjs(timestamp).fromNow()
}
// 获取图标
const getIcon = (type: string) => {
const icons: Record<string, string> = {
'flashsale': 'Lightning',
'order': 'List',
'system': 'InfoFilled'
}
return icons[type] || 'InfoFilled'
}
// 获取图标类名
const getIconClass = (type: string) => {
const classes: Record<string, string> = {
'flashsale': 'notification-icon',
'order': 'notification-icon',
'system': 'notification-icon muted'
}
return classes[type] || 'text-gray-500'
}
// 标记已读
const markAsRead = async (id: number | string) => {
const notification = notifications.value.find(n => String(n.id) === String(id))
if (notification && !notification.read) {
try {
await notificationApi.markAsRead(Number(id))
notification.read = true
} catch {
// ignore
}
}
}
// 全部标记已读
const markAllAsRead = async () => {
try {
await notificationApi.markAllAsRead()
notifications.value.forEach(n => n.read = true)
} catch {
ElMessage.error('操作失败')
}
}
// 清空消息
const clearAll = async () => {
try {
await notificationApi.clearAll()
notifications.value = []
visible.value = false
} catch {
ElMessage.error('操作失败')
}
}
// 处理点击
const handleClick = (item: NotificationItem) => {
markAsRead(item.id)
if (item.link) {
router.push(item.link)
visible.value = false
}
}
onMounted(() => {
fetchNotifications()
// 每60秒轮询一次
pollTimer = setInterval(fetchNotifications, 60000)
})
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
})
</script>
<style lang="scss" scoped>
.notification-center {
.notification-trigger {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 6px;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
color: #2b2b27;
&:hover {
color: #171715;
}
}
}
.notification-icon {
color: #44443f;
&.muted {
color: #7b7b74;
}
}
.notification-content {
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
.title {
font-size: 16px;
font-weight: 500;
}
.actions {
display: flex;
gap: 8px;
}
}
.notification-tabs {
:deep(.el-tabs__header) {
margin: 0;
padding: 0 16px;
}
:deep(.el-tabs__content) {
padding: 0;
}
}
.notification-list {
max-height: 400px;
overflow-y: auto;
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #f7f7f6;
}
&.unread {
background-color: #f7f7f6;
.title {
font-weight: 600;
}
}
.content {
flex: 1;
min-width: 0;
.title {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.message {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 12px;
color: #c0c4cc;
}
}
}
}
.notification-footer {
padding: 12px 16px;
border-top: 1px solid #e4e7ed;
text-align: center;
.view-all {
color: #44443f;
text-decoration: none;
font-size: 14px;
&:hover {
color: #171715;
}
}
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div :class="['safe-image', wrapperClass, { 'is-clickable': clickable }]" @click="handleClick">
<div v-if="!loaded" class="safe-image__placeholder">
<div class="safe-image__shimmer"></div>
</div>
<img
:alt="alt"
:class="['safe-image__img', imgClass, { 'is-loaded': loaded }]"
:loading="lazy ? 'lazy' : 'eager'"
:src="currentSrc"
@error="handleError"
@load="handleLoad"
>
</div>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {DEFAULT_PRODUCT_IMAGE, resolveImageUrl} from '@/utils/image'
interface Props {
src?: string | null
alt?: string
imgClass?: string
wrapperClass?: string
lazy?: boolean
clickable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
imgClass: '',
wrapperClass: '',
lazy: true,
clickable: false,
})
const emit = defineEmits<{
click: []
}>()
const currentSrc = ref(DEFAULT_PRODUCT_IMAGE)
const loaded = ref(false)
const fallbackApplied = ref(false)
const resetSource = () => {
currentSrc.value = resolveImageUrl(props.src)
loaded.value = false
fallbackApplied.value = false
}
watch(() => props.src, resetSource, {immediate: true})
const handleLoad = () => {
loaded.value = true
}
const handleError = () => {
if (fallbackApplied.value) {
loaded.value = true
return
}
fallbackApplied.value = true
currentSrc.value = DEFAULT_PRODUCT_IMAGE
}
const handleClick = () => {
if (props.clickable) {
emit('click')
}
}
</script>
<style lang="scss" scoped>
.safe-image {
position: relative;
overflow: hidden;
background: #f4ede4;
&.is-clickable {
cursor: pointer;
}
&__placeholder {
position: absolute;
inset: 0;
background: #f4ede4;
}
&__shimmer {
width: 100%;
height: 100%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(0, 0, 0, 0.06) 50%, rgba(255, 255, 255, 0) 100%);
animation: shimmer 1.4s infinite;
}
&__img {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.2s ease;
display: block;
&.is-loaded {
opacity: 1;
}
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,467 @@
<template>
<div class="search-component">
<el-popover
:visible="visible"
:width="600"
placement="bottom"
trigger="click"
@update:visible="val => visible = val"
>
<template #reference>
<el-input
v-model="searchQuery"
class="search-input"
placeholder="搜索商品、限时活动..."
@focus="handleFocus"
@keyup.enter="handleQuickSearch"
>
<template #prefix>
<el-icon>
<Search/>
</el-icon>
</template>
<template #suffix>
<el-button
v-if="searchQuery"
circle
size="small"
text
@click.stop="clearSearch"
>
<el-icon>
<Close/>
</el-icon>
</el-button>
</template>
</el-input>
</template>
<div class="search-panel">
<!-- 搜索历史 -->
<div v-if="!searchQuery && searchHistory.length > 0" class="search-section">
<div class="section-header">
<span class="title">搜索历史</span>
<el-button size="small" text @click="clearHistory">
清空
</el-button>
</div>
<div class="tag-list">
<el-tag
v-for="item in searchHistory"
:key="item"
closable
@click="selectHistory(item)"
@close="removeHistory(item)"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 热门搜索 -->
<div v-if="!searchQuery" class="search-section">
<div class="section-header">
<span class="title">热门搜索</span>
</div>
<div class="tag-list">
<el-tag
v-for="item in hotSearches"
:key="item"
@click="selectHot(item)"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 搜索建议 -->
<div v-if="searchQuery && suggestions.length > 0" class="search-suggestions">
<div class="section-header">
<span class="title">搜索建议</span>
</div>
<div class="suggestion-list">
<div
v-for="item in suggestions"
:key="item.id"
class="suggestion-item"
@click="selectSuggestion(item)"
>
<el-icon :size="16" class="mr-2">
<component :is="item.type === 'product' ? 'ShoppingBag' : 'Lightning'"/>
</el-icon>
<div class="content">
<div class="name" v-html="highlightKeyword(item.name)"></div>
<div class="info">
<span class="type">{{ item.type === 'product' ? '商品' : '限时' }}</span>
<span class="price">¥{{ item.price }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 高级搜索 -->
<div class="advanced-search">
<el-collapse v-model="activeCollapse">
<el-collapse-item name="advanced">
<template #title>
<span class="search-advanced-title">
<el-icon><Setting/></el-icon>
高级搜索
</span>
</template>
<div class="advanced-form">
<el-form :model="advancedForm" label-width="80px" size="small">
<el-form-item label="商品分类">
<el-select v-model="advancedForm.category" placeholder="选择分类">
<el-option label="全部分类" value=""/>
<el-option v-for="item in categories" :key="item" :label="item" :value="item"/>
<el-option label="图书音像" value="books"/>
</el-select>
</el-form-item>
<el-form-item label="价格区间">
<div class="flex gap-2">
<el-input-number
v-model="advancedForm.minPrice"
:min="0"
placeholder="最低价"
/>
<span>-</span>
<el-input-number
v-model="advancedForm.maxPrice"
:min="0"
placeholder="最高价"
/>
</div>
</el-form-item>
<el-form-item label="搜索类型">
<el-checkbox-group v-model="advancedForm.types">
<el-checkbox label="product">商品</el-checkbox>
<el-checkbox label="flashsale">限时</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleAdvancedSearch">
搜索
</el-button>
<el-button @click="resetAdvanced">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, watch, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {debounce} from 'lodash-es'
import {productApi} from '@/api/modules/product'
import {flashsaleApi} from '@/api/modules/flashsale'
const router = useRouter()
const visible = ref(false)
const searchQuery = ref('')
const activeCollapse = ref<string[]>([])
// 搜索历史
const searchHistory = ref<string[]>([])
// 热门搜索
const hotSearches = ref([
'iPhone 15',
'MacBook Pro',
'限时活动',
'AirPods',
'限时特价',
'新品上市'
])
// 搜索建议
const suggestions = ref<any[]>([])
const categories = ref<string[]>([])
// 高级搜索表单
const advancedForm = reactive({
category: '',
minPrice: undefined as number | undefined,
maxPrice: undefined as number | undefined,
types: ['product', 'flashsale']
})
// 从localStorage加载搜索历史
const loadSearchHistory = () => {
const history = localStorage.getItem('searchHistory')
if (history) {
searchHistory.value = JSON.parse(history)
}
}
// 保存搜索历史
const saveSearchHistory = (keyword: string) => {
if (!keyword.trim()) return
// 去重并限制数量
const history = searchHistory.value.filter(item => item !== keyword)
history.unshift(keyword)
searchHistory.value = history.slice(0, 10)
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
}
// 清空搜索历史
const clearHistory = () => {
searchHistory.value = []
localStorage.removeItem('searchHistory')
}
// 移除单个历史
const removeHistory = (item: string) => {
searchHistory.value = searchHistory.value.filter(h => h !== item)
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
}
// 选择历史记录
const selectHistory = (item: string) => {
searchQuery.value = item
handleQuickSearch()
}
// 选择热门搜索
const selectHot = (item: string) => {
searchQuery.value = item
handleQuickSearch()
}
// 选择搜索建议
const selectSuggestion = (item: any) => {
if (item.type === 'product') {
router.push(`/product/${item.id}`)
} else {
router.push(`/flashsale/${item.id}`)
}
visible.value = false
saveSearchHistory(item.name)
}
// 高亮关键词
const highlightKeyword = (text: string) => {
if (!searchQuery.value) return text
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
return text.replace(regex, '<span class="search-highlight">$1</span>')
}
// 获取搜索建议
const fetchSuggestions = debounce(async () => {
if (!searchQuery.value.trim()) {
suggestions.value = []
return
}
try {
const [productRes, flashSaleRes] = await Promise.all([
productApi.getList({keyword: searchQuery.value, page: 0, size: 5}),
flashsaleApi.getList({page: 0, size: 6}),
])
const productSuggestions = productRes.success
? productRes.data.content.map((item) => ({
id: item.id,
type: 'product',
name: item.name,
price: item.price,
}))
: []
const flashSaleSuggestions = flashSaleRes.success
? flashSaleRes.data.content
.filter((item) => item.productName.includes(searchQuery.value))
.slice(0, 3)
.map((item) => ({
id: item.id,
type: 'flashsale',
name: item.productName,
price: item.flashPrice,
}))
: []
suggestions.value = [...productSuggestions, ...flashSaleSuggestions].slice(0, 8)
} catch (error) {
suggestions.value = []
}
}, 300)
// 快速搜索
const handleQuickSearch = () => {
if (!searchQuery.value.trim()) return
saveSearchHistory(searchQuery.value)
router.push({
path: '/products',
query: {keyword: searchQuery.value}
})
visible.value = false
}
// 高级搜索
const handleAdvancedSearch = () => {
const params: any = {
keyword: searchQuery.value
}
if (advancedForm.category) params.category = advancedForm.category
if (advancedForm.minPrice !== undefined) params.minPrice = advancedForm.minPrice
if (advancedForm.maxPrice !== undefined) params.maxPrice = advancedForm.maxPrice
router.push({
path: '/products',
query: params
})
visible.value = false
saveSearchHistory(searchQuery.value)
}
// 重置高级搜索
const resetAdvanced = () => {
advancedForm.category = ''
advancedForm.minPrice = undefined
advancedForm.maxPrice = undefined
advancedForm.types = ['product', 'flashsale']
}
// 清空搜索
const clearSearch = () => {
searchQuery.value = ''
suggestions.value = []
}
// 处理焦点
const handleFocus = () => {
visible.value = true
}
// 监听搜索输入
watch(searchQuery, () => {
fetchSuggestions()
})
onMounted(async () => {
loadSearchHistory()
const res = await productApi.getCategories()
categories.value = res.success ? res.data : []
})
</script>
<style lang="scss" scoped>
.search-component {
.search-input {
width: 300px;
@media (max-width: 768px) {
width: 200px;
}
}
}
.search-advanced-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #44443f;
}
.search-panel {
.search-section {
margin-bottom: 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.title {
font-size: 14px;
font-weight: 500;
color: #303133;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.el-tag {
cursor: pointer;
&:hover {
background-color: #efefed;
}
}
}
}
.search-suggestions {
.suggestion-list {
.suggestion-item {
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: #f7f7f6;
}
.content {
flex: 1;
.name {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.info {
display: flex;
gap: 10px;
font-size: 12px;
color: #909399;
.type {
padding: 0 6px;
background-color: #efefed;
border-radius: 2px;
}
.price {
color: #2b2b27;
font-weight: 500;
}
}
}
}
}
}
.advanced-search {
margin-top: 20px;
border-top: 1px solid #d8cebf;
padding-top: 10px;
.advanced-form {
padding: 10px 0;
}
}
}
</style>

View File

@@ -0,0 +1,219 @@
import {ref, onUnmounted} from 'vue'
import {ElNotification} from 'element-plus'
import {useUserStore} from '@/stores/user'
export interface WebSocketMessage {
type: 'FLASH_SALE_START' | 'FLASH_SALE_END' | 'ORDER_STATUS' | 'STOCK_UPDATE' | 'SYSTEM_NOTICE'
data: any
timestamp: number
}
class WebSocketService {
private ws: WebSocket | null = null
private reconnectTimer: number | null = null
private heartbeatTimer: number | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private listeners: Map<string, Set<Function>> = new Map()
constructor() {
this.connect()
}
private connect() {
const userStore = useUserStore()
if (!userStore.token) {
console.log('WebSocket: 用户未登录,跳过连接')
return
}
const wsUrl = `${import.meta.env.VITE_WS_URL}?token=${userStore.token}`
try {
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket 连接成功')
this.reconnectAttempts = 0
this.startHeartbeat()
// 发送认证信息
this.send({
type: 'AUTH',
token: userStore.token
})
}
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
this.handleMessage(message)
} catch (error) {
console.error('WebSocket 消息解析失败:', error)
}
}
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
}
this.ws.onclose = () => {
console.log('WebSocket 连接关闭')
this.stopHeartbeat()
this.attemptReconnect()
}
} catch (error) {
console.error('WebSocket 连接失败:', error)
this.attemptReconnect()
}
}
private handleMessage(message: WebSocketMessage) {
// 触发消息监听器
const listeners = this.listeners.get(message.type)
if (listeners) {
listeners.forEach(listener => listener(message.data))
}
// 显示通知
switch (message.type) {
case 'FLASH_SALE_START':
ElNotification({
title: '限时开始',
message: `${message.data.productName} 限时活动已开始!`,
type: 'success',
duration: 5000
})
break
case 'FLASH_SALE_END':
ElNotification({
title: '限时结束',
message: `${message.data.productName} 限时活动已结束`,
type: 'info',
duration: 3000
})
break
case 'ORDER_STATUS':
ElNotification({
title: '订单状态更新',
message: message.data.message,
type: 'info',
duration: 3000
})
break
case 'STOCK_UPDATE':
// 库存更新,不显示通知,只触发监听器
break
case 'SYSTEM_NOTICE':
ElNotification({
title: '系统通知',
message: message.data.content,
type: message.data.level || 'info',
duration: 5000
})
break
}
}
private startHeartbeat() {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({type: 'PING'}))
}
}, 30000) // 30秒心跳
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket 重连失败,已达最大重试次数')
return
}
this.reconnectAttempts++
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
console.log(`WebSocket 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
this.reconnectTimer = window.setTimeout(() => {
this.connect()
}, delay)
}
public send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
} else {
console.warn('WebSocket 未连接,无法发送消息')
}
}
public on(type: string, callback: Function) {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set())
}
this.listeners.get(type)!.add(callback)
}
public off(type: string, callback: Function) {
const listeners = this.listeners.get(type)
if (listeners) {
listeners.delete(callback)
}
}
public disconnect() {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.ws) {
this.ws.close()
this.ws = null
}
}
}
// 单例实例
let wsService: WebSocketService | null = null
export function useWebSocket() {
if (!wsService) {
wsService = new WebSocketService()
}
const subscribe = (type: string, callback: Function) => {
wsService?.on(type, callback)
}
const unsubscribe = (type: string, callback: Function) => {
wsService?.off(type, callback)
}
const send = (data: any) => {
wsService?.send(data)
}
onUnmounted(() => {
// 组件卸载时不断开连接,保持全局连接
})
return {
subscribe,
unsubscribe,
send
}
}

View File

@@ -0,0 +1,355 @@
<template>
<div class="admin-layout">
<el-container>
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '200px'" class="admin-sidebar">
<div class="logo-container">
<el-icon :size="24" class="logo-icon">
<Lightning/>
</el-icon>
<transition name="fade">
<span v-if="!isCollapse" class="logo-text">管理后台</span>
</transition>
</div>
<el-menu
:collapse="isCollapse"
:collapse-transition="false"
:default-active="activeMenu"
router
>
<el-menu-item index="/admin">
<el-icon>
<DataLine/>
</el-icon>
<template #title>仪表盘</template>
</el-menu-item>
<el-menu-item index="/admin/products">
<el-icon>
<ShoppingBag/>
</el-icon>
<template #title>商品管理</template>
</el-menu-item>
<el-menu-item index="/admin/groupbuying">
<el-icon>
<Connection/>
</el-icon>
<template #title>拼团管理</template>
</el-menu-item>
<el-menu-item index="/admin/orders">
<el-icon>
<List/>
</el-icon>
<template #title>订单管理</template>
</el-menu-item>
<el-menu-item index="/admin/users">
<el-icon>
<User/>
</el-icon>
<template #title>用户管理</template>
</el-menu-item>
<el-menu-item index="/admin/returns">
<el-icon>
<RefreshLeft/>
</el-icon>
<template #title>退货管理</template>
</el-menu-item>
<el-menu-item index="/admin/reviews">
<el-icon>
<ChatDotRound/>
</el-icon>
<template #title>评价管理</template>
</el-menu-item>
<el-menu-item index="/admin/favorites">
<el-icon>
<Star/>
</el-icon>
<template #title>收藏管理</template>
</el-menu-item>
<el-menu-item index="/admin/monitor">
<el-icon>
<Monitor/>
</el-icon>
<template #title>系统监控</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部导航 -->
<el-header class="admin-header">
<div class="header-left">
<el-icon
:size="20"
class="collapse-btn"
@click="isCollapse = !isCollapse"
>
<component :is="isCollapse ? 'Expand' : 'Fold'"/>
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/admin' }">
管理后台
</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentPageTitle">
{{ currentPageTitle }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 全屏 -->
<el-icon class="header-icon" @click="toggleFullscreen">
<FullScreen/>
</el-icon>
<!-- 刷新 -->
<el-icon class="header-icon" @click="handleRefresh">
<Refresh/>
</el-icon>
<!-- 用户信息 -->
<el-dropdown trigger="click">
<div class="user-info">
<el-avatar :size="32" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
</el-avatar>
<span class="username">{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
<el-icon>
<SwitchButton/>
</el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="admin-main">
<router-view v-slot="{ Component }">
<transition mode="out-in" name="fade-transform">
<component :is="Component"/>
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</div>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessageBox} from 'element-plus'
import {useUserStore} from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const activeMenu = computed(() => route.path)
// 获取当前页面标题
const currentPageTitle = computed(() => {
const titles: Record<string, string> = {
'/admin': '',
'/admin/products': '商品管理',
'/admin/groupbuying': '拼团管理',
'/admin/orders': '订单管理',
'/admin/users': '用户管理',
'/admin/returns': '退货管理',
'/admin/reviews': '评价管理',
'/admin/favorites': '收藏管理',
'/admin/monitor': '系统监控',
}
return titles[route.path] || ''
})
// 全屏切换
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
document.exitFullscreen()
}
}
// 刷新页面
const handleRefresh = () => {
window.location.reload()
}
// 退出登录
const handleLogout = async () => {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await userStore.logout()
}
</script>
<style lang="scss" scoped>
.admin-layout {
height: 100vh;
background: transparent;
.el-container {
height: 100%;
}
}
.admin-sidebar {
background: #fffaf2;
border-right: 1px solid #d8cebf;
transition: width 0.3s;
.logo-container {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #171715;
border-bottom: 1px solid #d8cebf;
.logo-icon {
color: #171715;
}
.logo-text {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
}
}
.el-menu {
border-right: none;
background: transparent;
padding: 12px 10px;
:deep(.el-menu-item) {
color: #171715;
margin-bottom: 6px;
border-radius: 12px;
&:hover {
background-color: #f4ede4 !important;
}
&.is-active {
color: #171715;
background-color: #fffdf8 !important;
box-shadow: inset 0 0 0 1px #171715;
}
}
}
}
.admin-header {
background: rgba(255, 250, 242, 0.92);
backdrop-filter: none;
border-bottom: 1px solid #d8cebf;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
.header-left {
display: flex;
align-items: center;
gap: 20px;
.collapse-btn {
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #171715;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
.header-icon {
cursor: pointer;
font-size: 18px;
transition: color 0.3s;
&:hover {
color: #171715;
}
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 10px;
border: 1px solid #d8cebf;
border-radius: 999px;
background: #fffaf2;
.username {
font-size: 14px;
color: #2b2b27;
}
}
}
}
.admin-main {
background: transparent;
padding: 24px;
}
// 动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="main-layout">
<AppHeader/>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition mode="out-in" name="fade-transform">
<component :is="Component"/>
</transition>
</router-view>
</main>
<AppFooter/>
</div>
</template>
<script lang="ts" setup>
import AppHeader from '@/components/common/AppHeader.vue'
import AppFooter from '@/components/common/AppFooter.vue'
</script>
<style lang="scss" scoped>
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding-top: var(--app-header-height);
background: transparent;
}
// 路由切换动画
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,27 @@
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 App from './App.vue'
import router from './router'
// 全局样式
import 'element-plus/dist/index.css'
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')

View File

@@ -0,0 +1,358 @@
<template>
<div class="admin-dashboard">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-icon tone-1">
<el-icon>
<User/>
</el-icon>
</div>
<div>
<div class="stat-value">{{ statistics.totalUsers }}</div>
<div class="stat-label">用户总数</div>
<div class="stat-desc">在线 {{ userStats.onlineUsers }} </div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tone-2">
<el-icon>
<ShoppingBag/>
</el-icon>
</div>
<div>
<div class="stat-value">{{ statistics.totalProducts }}</div>
<div class="stat-label">商品总数</div>
<div class="stat-desc">低库存 {{ productStats.lowStockProducts }} </div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tone-3">
<el-icon>
<List/>
</el-icon>
</div>
<div>
<div class="stat-value">{{ statistics.totalOrders }}</div>
<div class="stat-label">订单总数</div>
<div class="stat-desc">今日新增 {{ statistics.todayOrders }} </div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tone-4">
<el-icon>
<Coin/>
</el-icon>
</div>
<div>
<div class="stat-value">¥{{ formatCurrency(statistics.totalAmount) }}</div>
<div class="stat-label">累计成交额</div>
<div class="stat-desc">活动中 {{ statistics.activeFlashSales }} </div>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">订单趋势</h3>
<p class="panel-subtitle">最近订单金额分布</p>
</div>
<el-button text type="primary" @click="loadDashboardData">刷新</el-button>
</div>
<div ref="salesChartRef" class="chart-container"></div>
</div>
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">商品状态占比</h3>
<p class="panel-subtitle">沿用 JSP 仪表盘的结构化总览</p>
</div>
</div>
<div ref="categoryChartRef" class="chart-container"></div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">最新订单</h3>
<p class="panel-subtitle">映射 JSP 仪表盘最近订单表格</p>
</div>
<el-button text type="primary" @click="router.push('/admin/orders')">查看全部</el-button>
</div>
<el-table v-loading="loading" :data="recentOrders" stripe>
<el-table-column label="订单号" min-width="120" prop="orderNo"/>
<el-table-column label="用户" min-width="100" prop="username"/>
<el-table-column label="商品" min-width="140" prop="productName" show-overflow-tooltip/>
<el-table-column label="金额" min-width="100" prop="totalAmount">
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="100" prop="status">
<template #default="{ row }">
<el-tag :type="getOrderStatusType(row.status)">{{ getOrderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">热门商品</h3>
<p class="panel-subtitle">对齐 JSP 后台热门商品模块</p>
</div>
<el-button text type="primary" @click="router.push('/admin/products')">查看全部</el-button>
</div>
<el-table v-loading="loading" :data="hotProducts" stripe>
<el-table-column label="商品名称" min-width="160" prop="name" show-overflow-tooltip/>
<el-table-column label="价格" min-width="100" prop="price">
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
</el-table-column>
<el-table-column label="销量" min-width="80" prop="sales"/>
<el-table-column label="库存" min-width="100" prop="stock">
<template #default="{ row }">
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {nextTick, onMounted, onUnmounted, reactive, ref} from 'vue'
import {useRouter} from 'vue-router'
import * as echarts from 'echarts'
import {adminApi} from '@/api/modules/admin'
import type {
AdminDashboardStats,
AdminHotProductRow,
AdminProductStats,
AdminRecentOrderRow,
AdminUserStats,
} from '@/types/admin'
const router = useRouter()
const loading = ref(false)
const statistics = reactive<AdminDashboardStats>({
totalUsers: 0,
totalProducts: 0,
totalOrders: 0,
totalAmount: 0,
todayOrders: 0,
paidOrders: 0,
pendingOrders: 0,
activeFlashSales: 0,
})
const userStats = reactive<AdminUserStats>({
totalUsers: 0,
activeUsers: 0,
newUsers: 0,
onlineUsers: 0,
})
const productStats = reactive<AdminProductStats>({
totalProducts: 0,
activeProducts: 0,
inactiveProducts: 0,
lowStockProducts: 0,
})
const recentOrders = ref<AdminRecentOrderRow[]>([])
const hotProducts = ref<AdminHotProductRow[]>([])
const salesChartRef = ref<HTMLElement | null>(null)
const categoryChartRef = ref<HTMLElement | null>(null)
let salesChart: echarts.ECharts | null = null
let categoryChart: echarts.ECharts | null = null
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
const getOrderStatusType = (status: string) => {
const map: Record<string, string> = {
PENDING: 'warning',
PAID: 'primary',
SHIPPED: 'success',
COMPLETED: 'success',
CANCELLED: 'info',
}
return map[status] || 'info'
}
const getOrderStatusText = (status: string) => {
const map: Record<string, string> = {
PENDING: '待支付',
PAID: '已支付',
SHIPPED: '已发货',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
return map[status] || status
}
const renderSalesChart = () => {
if (!salesChartRef.value) return
if (!salesChart) {
salesChart = echarts.init(salesChartRef.value)
}
salesChart.setOption({
tooltip: {trigger: 'axis'},
grid: {left: 24, right: 12, top: 24, bottom: 24, containLabel: true},
xAxis: {
type: 'category',
data: recentOrders.value.map((item) => item.orderNo),
axisLabel: {rotate: 30},
},
yAxis: {
type: 'value',
axisLabel: {formatter: '¥{value}'},
},
series: [
{
type: 'bar',
data: recentOrders.value.map((item) => item.totalAmount),
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: '#171715',
},
},
],
})
}
const renderCategoryChart = () => {
if (!categoryChartRef.value) return
if (!categoryChart) {
categoryChart = echarts.init(categoryChartRef.value)
}
categoryChart.setOption({
tooltip: {trigger: 'item'},
legend: {bottom: 0},
color: ['#171715', '#5e5e58', '#9f9f99'],
series: [
{
name: '商品状态',
type: 'pie',
radius: ['42%', '72%'],
data: [
{value: productStats.activeProducts, name: '上架商品'},
{value: productStats.inactiveProducts, name: '下架商品'},
{value: productStats.lowStockProducts, name: '低库存商品'},
],
},
],
})
}
const loadDashboardData = async () => {
loading.value = true
try {
const [dashboardRes, userRes, productRes, recentRes, hotRes] = await Promise.all([
adminApi.getDashboardStats(),
adminApi.getUserStats(),
adminApi.getProductStats(),
adminApi.getRecentOrders(6),
adminApi.getHotProducts(6),
])
Object.assign(statistics, dashboardRes.data)
Object.assign(userStats, userRes.data)
Object.assign(productStats, productRes.data)
recentOrders.value = recentRes.data
hotProducts.value = hotRes.data
await nextTick()
renderSalesChart()
renderCategoryChart()
} finally {
loading.value = false
}
}
const handleResize = () => {
salesChart?.resize()
categoryChart?.resize()
}
onMounted(() => {
loadDashboardData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
salesChart?.dispose()
categoryChart?.dispose()
})
</script>
<style lang="scss" scoped>
.admin-dashboard {
.stat-card {
@apply bg-white rounded-xl p-5 flex items-center gap-4;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.stat-icon {
@apply w-12 h-12 rounded-xl flex items-center justify-center text-xl;
background: #f4ede4;
color: #171715;
border: 1px solid #d8cebf;
&.tone-2 {
background: #ffffff;
}
&.tone-3 {
background: #ffffff;
}
&.tone-4 {
background: #ffffff;
}
}
.stat-value {
@apply text-2xl font-bold text-slate-900;
}
.stat-label {
@apply text-sm text-slate-500 mt-1;
}
.stat-desc {
@apply text-xs text-slate-400 mt-2;
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.panel-header {
@apply flex items-center justify-between mb-4 gap-4;
}
.panel-title {
@apply text-lg font-semibold text-slate-900;
}
.panel-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.chart-container {
height: 320px;
}
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h2 class="page-title">收藏管理</h2>
<p class="page-subtitle">查看用户收藏关系并支持后台删除</p>
</div>
<div class="actions">
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat blue">
<div class="mini-stat__value">{{ stats.totalFavorites }}</div>
<div class="mini-stat__label">收藏总数</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ stats.favoriteUsers }}</div>
<div class="mini-stat__label">收藏用户数</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.favoriteProducts }}</div>
<div class="mini-stat__label">被收藏商品数</div>
</div>
<div class="mini-stat purple">
<div class="mini-stat__value">{{ stats.todayFavorites }}</div>
<div class="mini-stat__label">今日新增收藏</div>
</div>
</div>
<div class="panel-card filter-card">
<el-input v-model="keyword" clearable placeholder="搜索用户 / 商品" @keyup.enter="loadFavorites"/>
<el-button type="primary" @click="loadFavorites">搜索</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="favorites" stripe>
<el-table-column label="商品" min-width="180" prop="productName" show-overflow-tooltip/>
<el-table-column label="分类" prop="productCategory" width="120"/>
<el-table-column label="用户" prop="username" width="120"/>
<el-table-column label="收藏时间" min-width="170" prop="createdAt"/>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button text type="danger" @click="removeFavorite(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination v-model:current-page="page" v-model:page-size="size" :page-sizes="[10,20,50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" @current-change="loadFavorites"
@size-change="loadFavorites"/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {onMounted, reactive, ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {adminApi} from '@/api/modules/admin'
import type {AdminFavoriteRow, AdminFavoriteStats} from '@/types/admin'
const loading = ref(false)
const keyword = ref('')
const page = ref(1)
const size = ref(10)
const total = ref(0)
const favorites = ref<AdminFavoriteRow[]>([])
const stats = reactive<AdminFavoriteStats>({
totalFavorites: 0,
favoriteUsers: 0,
favoriteProducts: 0,
todayFavorites: 0
})
const loadStats = async () => {
const res = await adminApi.getFavoriteStats()
Object.assign(stats, res.data)
}
const loadFavorites = async () => {
loading.value = true
try {
const res = await adminApi.getFavorites({page: page.value, size: size.value, keyword: keyword.value || undefined})
favorites.value = res.data.favorites
total.value = res.data.total
} finally {
loading.value = false
}
}
const removeFavorite = async (id: number) => {
await ElMessageBox.confirm('确定删除该收藏记录吗?', '提示', {type: 'warning'})
await adminApi.deleteFavorite(id)
ElMessage.success('删除成功')
loadStats();
loadFavorites()
}
const migrateItems = async () => {
const res = await adminApi.migrateLegacyOrderItems()
ElMessage.success(`迁移完成:迁移 ${res.data.migrated} 条,跳过 ${res.data.skipped}`)
}
const reloadData = async () => {
await Promise.all([loadStats(), loadFavorites()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns:repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.mini-stat__value {
@apply text-3xl font-bold;
}
.mini-stat__label {
@apply text-sm opacity-90 mt-2;
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns:1fr 100px;
gap: 12px;
}
.table-footer {
@apply flex justify-end mt-4;
}
</style>

View File

@@ -0,0 +1,604 @@
<template>
<div class="admin-flashsales page-shell">
<div class="page-header">
<div>
<h2 class="page-title">限时管理</h2>
<p class="page-subtitle">覆盖 JSP 的活动列表发布暂停恢复结束与详情查看</p>
</div>
<div class="page-actions">
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
<el-button type="primary" @click="openCreateDialog">
<el-icon>
<Plus/>
</el-icon>
创建限时
</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat purple">
<div class="mini-stat__value">{{ stats.totalFlashSales }}</div>
<div class="mini-stat__label">活动总数</div>
</div>
<div class="mini-stat red">
<div class="mini-stat__value">{{ stats.activeFlashSales }}</div>
<div class="mini-stat__label">进行中</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.upcomingFlashSales }}</div>
<div class="mini-stat__label">即将开始</div>
</div>
<div class="mini-stat gray">
<div class="mini-stat__value">{{ stats.endedFlashSales }}</div>
<div class="mini-stat__label">已结束</div>
</div>
</div>
<div class="panel-card filter-card">
<el-input v-model="query.keyword" clearable placeholder="搜索商品名称" @keyup.enter="loadFlashSales">
<template #prefix>
<el-icon>
<Search/>
</el-icon>
</template>
</el-input>
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
<el-option label="即将开始" value="UPCOMING"/>
<el-option label="进行中" value="ACTIVE"/>
<el-option label="已暂停" value="PAUSED"/>
<el-option label="已结束" value="ENDED"/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="displayFlashSales" stripe>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="商品" min-width="240">
<template #default="{ row }">
<div class="product-cell">
<SafeImage :alt="row.productName" :src="row.productImageUrl" img-class="product-image"
wrapper-class="product-image"/>
<div>
<div class="product-name">{{ row.productName }}</div>
<div class="product-meta">商品ID{{ row.productId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="原价" prop="originalPrice" width="110">
<template #default="{ row }">¥{{ formatCurrency(row.originalPrice) }}</template>
</el-table-column>
<el-table-column label="活动价" prop="flashPrice" width="110">
<template #default="{ row }">¥{{ formatCurrency(row.flashPrice) }}</template>
</el-table-column>
<el-table-column label="总库存" prop="flashStock" width="100"/>
<el-table-column label="剩余库存" prop="remainingStock" width="100"/>
<el-table-column label="时间范围" min-width="220">
<template #default="{ row }">
<div>{{ formatTime(row.startTime) }}</div>
<div class="text-slate-400"> {{ formatTime(row.endTime) }}</div>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="110">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="320">
<template #default="{ row }">
<el-button text type="primary" @click="openDetail(row)">查看</el-button>
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="row.status === 'UPCOMING'" text type="success" @click="changeStatus('publish', row)">发布
</el-button>
<el-button v-if="row.status === 'ACTIVE'" text type="warning" @click="changeStatus('pause', row)">暂停
</el-button>
<el-button v-if="row.status === 'PAUSED'" text type="success" @click="changeStatus('resume', row)">恢复
</el-button>
<el-button v-if="row.status === 'ACTIVE' || row.status === 'PAUSED'" text type="danger"
@click="changeStatus('end', row)">结束
</el-button>
<el-button text type="danger" @click="removeFlashSale(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadFlashSales"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '创建限时活动' : '编辑限时活动'" width="760px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="关联商品" prop="productId">
<el-select v-model="form.productId" :disabled="formMode === 'edit'" filterable placeholder="请选择商品">
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id"/>
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="活动价格" prop="flashPrice">
<el-input-number v-model="form.flashPrice" :min="0.01" :precision="2" class="w-full"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="活动库存" prop="flashStock">
<el-input-number v-model="form.flashStock" :min="1" class="w-full"/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="form.startTime"
:disabled-date="disablePastDate"
class="w-full"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="form.endTime"
:disabled-date="disablePastDate"
class="w-full"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button :loading="saving" type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" title="限时详情" width="760px">
<div v-if="currentItem" class="detail-layout">
<SafeImage :alt="currentItem.productName" :src="currentItem.productImageUrl" img-class="detail-image"
wrapper-class="detail-image"/>
<div class="detail-content">
<h3>{{ currentItem.productName }}</h3>
<div class="price-line">
<span class="flash-price">¥{{ formatCurrency(currentItem.flashPrice) }}</span>
<span class="origin-price">¥{{ formatCurrency(currentItem.originalPrice) }}</span>
</div>
<div class="detail-grid">
<div><span>活动状态</span>{{ getStatusText(currentItem.status) }}</div>
<div><span>总库存</span>{{ currentItem.flashStock }}</div>
<div><span>剩余库存</span>{{ currentItem.remainingStock }}</div>
<div><span>限购</span>{{ currentItem.limitPerUser }} </div>
<div><span>开始时间</span>{{ formatTime(currentItem.startTime) }}</div>
<div><span>结束时间</span>{{ formatTime(currentItem.endTime) }}</div>
</div>
<el-progress :percentage="getStockRate(currentItem)" :stroke-width="10" class="mt-5"/>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {computed, onMounted, reactive, ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import type {FormInstance, FormRules} from 'element-plus'
import dayjs from 'dayjs'
import {flashsaleApi} from '@/api/modules/flashsale'
import {adminApi} from '@/api/modules/admin'
import type {FlashSale} from '@/types/api'
import SafeImage from '@/components/common/SafeImage.vue'
import type {AdminFlashSaleStats, AdminProductRow} from '@/types/admin'
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const detailVisible = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const flashSales = ref<FlashSale[]>([])
const currentItem = ref<FlashSale | null>(null)
const productOptions = ref<AdminProductRow[]>([])
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
const CREATE_START_LEAD_MINUTES = 5
const CREATE_DURATION_DAYS = 1
const query = reactive({
keyword: '',
status: '',
})
const pagination = reactive({
page: 1,
size: 10,
total: 0,
})
const stats = reactive<AdminFlashSaleStats>({
totalFlashSales: 0,
activeFlashSales: 0,
upcomingFlashSales: 0,
endedFlashSales: 0,
})
const buildDefaultStartTime = () => dayjs().add(CREATE_START_LEAD_MINUTES, 'minute').startOf('minute').format(TIME_FORMAT)
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(CREATE_DURATION_DAYS, 'day').format(TIME_FORMAT)
const form = reactive({
id: 0,
productId: undefined as number | undefined,
flashPrice: 0.01,
flashStock: 1,
startTime: buildDefaultStartTime(),
endTime: buildDefaultEndTime(),
})
const rules: FormRules = {
productId: [{required: true, message: '请选择商品', trigger: 'change'}],
flashPrice: [{required: true, message: '请输入活动价格', trigger: 'change'}],
flashStock: [{required: true, message: '请输入活动库存', trigger: 'change'}],
startTime: [{required: true, message: '请选择开始时间', trigger: 'change'}],
endTime: [{required: true, message: '请选择结束时间', trigger: 'change'}],
}
const displayFlashSales = computed(() => {
if (!query.keyword) return flashSales.value
return flashSales.value.filter((item) => item.productName.toLowerCase().includes(query.keyword.toLowerCase()))
})
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
const getStatusText = (status: string) => {
const map: Record<string, string> = {
UPCOMING: '即将开始',
ACTIVE: '进行中',
PAUSED: '已暂停',
ENDED: '已结束',
}
return map[status] || status
}
const getStatusType = (status: string) => {
const map: Record<string, string> = {
UPCOMING: 'warning',
ACTIVE: 'danger',
PAUSED: 'warning',
ENDED: 'info',
}
return map[status] || 'info'
}
const getStockRate = (item: FlashSale) => {
if (!item.flashStock) return 0
return Math.round((item.remainingStock / item.flashStock) * 100)
}
const disablePastDate = (date: Date) => {
if (formMode.value === 'edit') return false
return dayjs(date).endOf('day').isBefore(dayjs())
}
const validateTimeRange = () => {
const startTime = dayjs(form.startTime)
const endTime = dayjs(form.endTime)
if (!startTime.isValid() || !endTime.isValid()) {
ElMessage.error('开始时间或结束时间格式无效')
return false
}
if (formMode.value === 'create' && !startTime.isAfter(dayjs())) {
ElMessage.error('开始时间必须晚于当前时间')
return false
}
if (!endTime.isAfter(startTime)) {
ElMessage.error('结束时间必须晚于开始时间')
return false
}
return true
}
const resetForm = () => {
form.id = 0
form.productId = undefined
form.flashPrice = 0.01
form.flashStock = 1
form.startTime = buildDefaultStartTime()
form.endTime = buildDefaultEndTime(form.startTime)
}
const loadStats = async () => {
const res = await adminApi.getFlashSaleStats()
Object.assign(stats, res.data)
}
const loadProducts = async () => {
const res = await adminApi.getProducts({page: 1, size: 100})
productOptions.value = res.data.products.filter((item) => item.status === 1)
}
const loadFlashSales = async () => {
loading.value = true
try {
const res = await flashsaleApi.getList({
page: pagination.page - 1,
size: pagination.size,
status: query.status || undefined,
})
flashSales.value = res.data.content
pagination.total = res.data.totalElements
} finally {
loading.value = false
}
}
const openCreateDialog = () => {
resetForm()
formMode.value = 'create'
formVisible.value = true
}
const openEditDialog = (row: FlashSale) => {
formMode.value = 'edit'
form.id = row.id
form.productId = row.productId
form.flashPrice = row.flashPrice
form.flashStock = row.flashStock
form.startTime = row.startTime
form.endTime = row.endTime
formVisible.value = true
}
const openDetail = (row: FlashSale) => {
currentItem.value = row
detailVisible.value = true
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (!validateTimeRange()) return
saving.value = true
try {
const payload = {
productId: form.productId!,
flashPrice: form.flashPrice,
flashStock: form.flashStock,
startTime: form.startTime,
endTime: form.endTime,
}
if (formMode.value === 'create') {
await flashsaleApi.create(payload)
ElMessage.success('限时活动创建成功')
} else {
await flashsaleApi.update(form.id, {
flashPrice: form.flashPrice,
flashStock: form.flashStock,
startTime: form.startTime,
endTime: form.endTime,
})
ElMessage.success('限时活动更新成功')
}
formVisible.value = false
await reloadData()
} finally {
saving.value = false
}
})
}
const changeStatus = async (action: 'publish' | 'pause' | 'resume' | 'end', row: FlashSale) => {
const actionTextMap = {
publish: '发布',
pause: '暂停',
resume: '恢复',
end: '结束',
}
await ElMessageBox.confirm(`确定要${actionTextMap[action]}活动“${row.productName}”吗?`, '状态变更确认', {
type: 'warning',
})
if (action === 'publish') await flashsaleApi.publish(row.id)
if (action === 'pause') await flashsaleApi.pause(row.id)
if (action === 'resume') await flashsaleApi.resume(row.id)
if (action === 'end') await flashsaleApi.end(row.id)
ElMessage.success(`活动已${actionTextMap[action]}`)
await reloadData()
}
const removeFlashSale = async (row: FlashSale) => {
await ElMessageBox.confirm(`确定删除活动“${row.productName}”吗?`, '删除确认', {
type: 'warning',
})
await flashsaleApi.delete(row.id)
ElMessage.success('活动已删除')
await reloadData()
}
const handleSearch = () => {
pagination.page = 1
loadFlashSales()
}
const handleReset = () => {
query.keyword = ''
query.status = ''
handleSearch()
}
const handlePageSizeChange = () => {
pagination.page = 1
loadFlashSales()
}
const reloadData = async () => {
await Promise.all([loadStats(), loadProducts(), loadFlashSales()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.page-actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value {
@apply text-3xl font-bold;
}
&__label {
@apply text-sm opacity-90 mt-2;
}
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns: 1.4fr 180px 100px 100px;
gap: 12px;
}
.product-cell {
display: flex;
align-items: center;
gap: 12px;
}
.product-image,
.detail-image {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #d8cebf;
}
.detail-image {
width: 220px;
height: 220px;
}
.product-name {
@apply font-medium text-slate-900;
}
.product-meta {
@apply text-xs text-slate-400 mt-1;
}
.table-footer {
@apply flex justify-end mt-4;
}
.detail-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 20px;
}
.price-line {
@apply mt-4 mb-5 flex items-end gap-3;
}
.flash-price {
@apply text-3xl font-bold;
color: #171715;
}
.origin-price {
@apply text-lg text-slate-400 line-through;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
color: #475569;
}
.detail-grid span {
color: #94a3b8;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-card {
grid-template-columns: 1fr 1fr;
}
.detail-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,508 @@
<template>
<div class="admin-groupbuying page-shell">
<div class="page-header">
<div>
<h2 class="page-title">拼团管理</h2>
<p class="page-subtitle">创建和管理拼团活动查看团组详情</p>
</div>
<div class="page-actions">
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
<el-button type="primary" @click="openCreateDialog">
<el-icon>
<Plus/>
</el-icon>
创建拼团
</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat purple">
<div class="mini-stat__value">{{ stats.totalActivities }}</div>
<div class="mini-stat__label">活动总数</div>
</div>
<div class="mini-stat red">
<div class="mini-stat__value">{{ stats.activeActivities }}</div>
<div class="mini-stat__label">进行中</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.myGroups }}</div>
<div class="mini-stat__label">团组数</div>
</div>
<div class="mini-stat gray">
<div class="mini-stat__value">{{ stats.successGroups }}</div>
<div class="mini-stat__label">已成团</div>
</div>
</div>
<div class="panel-card filter-card">
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
<el-option label="草稿" value="DRAFT"/>
<el-option label="即将开始" value="UPCOMING"/>
<el-option label="进行中" value="ACTIVE"/>
<el-option label="已结束" value="ENDED"/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="list" stripe>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="商品" min-width="240">
<template #default="{ row }">
<div class="product-cell">
<SafeImage :alt="row.productName" :src="row.productImageUrl" img-class="product-image"
wrapper-class="product-image"/>
<div>
<div class="product-name">{{ row.productName }}</div>
<div class="product-meta">商品ID{{ row.productId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="原价" width="100">
<template #default="{ row }">¥{{ formatCurrency(row.productPrice) }}</template>
</el-table-column>
<el-table-column label="拼团价" width="100">
<template #default="{ row }"><span class="font-bold">¥{{ formatCurrency(row.groupPrice) }}</span></template>
</el-table-column>
<el-table-column label="成团人数" prop="requiredMembers" width="90"/>
<el-table-column label="库存" width="120">
<template #default="{ row }">{{ row.remainingStock }} / {{ row.totalStock }}</template>
</el-table-column>
<el-table-column label="时间" min-width="200">
<template #default="{ row }">
<div>{{ formatTime(row.startTime) }}</div>
<div class="text-slate-400"> {{ formatTime(row.endTime) }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ row.statusDescription }}</el-tag>
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="240">
<template #default="{ row }">
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="row.status === 'DRAFT'" text type="success" @click="publishActivity(row)">发布</el-button>
<el-tooltip :disabled="row.status !== 'ACTIVE'" content="进行中的活动不能删除" placement="top">
<span>
<el-button :disabled="row.status === 'ACTIVE'" text type="danger"
@click="removeActivity(row)">删除</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
@current-change="loadList"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<!-- 创建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑拼团活动' : '创建拼团活动'" width="760px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="关联商品" prop="productId">
<el-select v-model="form.productId" :disabled="!!editingId" class="w-full" filterable
placeholder="请选择商品">
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id"/>
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="拼团价格" prop="groupPrice">
<el-input-number v-model="form.groupPrice" :min="0.01" :precision="2" class="w-full"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总库存" prop="totalStock">
<el-input-number v-model="form.totalStock" :min="1" class="w-full"/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="成团人数" prop="requiredMembers">
<el-input-number v-model="form.requiredMembers" :max="100" :min="2" class="w-full"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="每人限购" prop="maxPerUser">
<el-input-number v-model="form.maxPerUser" :max="10" :min="1" class="w-full"/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="有效期(分钟)">
<el-input-number v-model="form.durationMinutes" :max="10080" :min="1" class="w-full"/>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="form.startTime"
:disabled-date="disablePastDate"
class="w-full"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="form.endTime"
:disabled-date="disablePastDate"
class="w-full"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button :loading="submitting" type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, onMounted} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import type {FormInstance, FormRules} from 'element-plus'
import dayjs from 'dayjs'
import type {GroupBuying, GroupBuyingStatistics} from '@/types/api'
import type {AdminProductRow} from '@/types/admin'
import {groupbuyingApi} from '@/api/modules/groupbuying'
import {adminApi} from '@/api/modules/admin'
import SafeImage from '@/components/common/SafeImage.vue'
const loading = ref(false)
const submitting = ref(false)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const list = ref<GroupBuying[]>([])
const productOptions = ref<AdminProductRow[]>([])
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
const stats = ref<GroupBuyingStatistics>({
totalActivities: 0,
activeActivities: 0,
myGroups: 0,
successGroups: 0,
totalSaved: 0,
})
const query = reactive({status: '' as string})
const pagination = reactive({page: 1, size: 10, total: 0})
const buildDefaultStartTime = () => dayjs().add(5, 'minute').startOf('minute').format(TIME_FORMAT)
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(1, 'day').format(TIME_FORMAT)
const form = reactive({
productId: undefined as number | undefined,
groupPrice: 0.01,
requiredMembers: 2,
durationMinutes: 1440,
totalStock: 100,
maxPerUser: 1,
startTime: buildDefaultStartTime(),
endTime: buildDefaultEndTime(),
})
const rules: FormRules = {
productId: [{required: true, message: '请选择商品', trigger: 'change'}],
groupPrice: [{required: true, message: '请输入拼团价格', trigger: 'change'}],
totalStock: [{required: true, message: '请输入总库存', trigger: 'change'}],
requiredMembers: [{required: true, message: '请输入成团人数', trigger: 'change'}],
startTime: [{required: true, message: '请选择开始时间', trigger: 'change'}],
endTime: [{required: true, message: '请选择结束时间', trigger: 'change'}],
}
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
const formatTime = (value: string) => dayjs(value).format(TIME_FORMAT)
const getStatusType = (status: string) => {
switch (status) {
case 'DRAFT':
return 'info'
case 'UPCOMING':
return 'warning'
case 'ACTIVE':
return 'success'
case 'ENDED':
return ''
default:
return 'info'
}
}
const disablePastDate = (date: Date) => {
if (editingId.value) return false
return dayjs(date).endOf('day').isBefore(dayjs())
}
const validateTimeRange = () => {
const startTime = dayjs(form.startTime)
const endTime = dayjs(form.endTime)
if (!startTime.isValid() || !endTime.isValid()) {
ElMessage.error('开始时间或结束时间格式无效')
return false
}
if (!editingId.value && !startTime.isAfter(dayjs())) {
ElMessage.error('开始时间必须晚于当前时间')
return false
}
if (!endTime.isAfter(startTime)) {
ElMessage.error('结束时间必须晚于开始时间')
return false
}
return true
}
const resetForm = () => {
editingId.value = null
form.productId = undefined
form.groupPrice = 0.01
form.requiredMembers = 2
form.durationMinutes = 1440
form.totalStock = 100
form.maxPerUser = 1
form.startTime = buildDefaultStartTime()
form.endTime = buildDefaultEndTime(form.startTime)
}
const loadProducts = async () => {
const res = await adminApi.getProducts({page: 1, size: 100})
productOptions.value = res.data.products.filter((item) => item.status === 1)
}
const loadList = async () => {
loading.value = true
try {
const [listRes, statsRes] = await Promise.all([
groupbuyingApi.getList({
page: pagination.page - 1,
size: pagination.size,
status: query.status || undefined,
}),
groupbuyingApi.getStatistics(),
])
list.value = listRes.data.content
pagination.total = listRes.data.totalElements
stats.value = statsRes.data
} catch (e) {
console.error('加载拼团活动失败', e)
} finally {
loading.value = false
}
}
const openCreateDialog = () => {
resetForm()
dialogVisible.value = true
}
const openEditDialog = (row: GroupBuying) => {
editingId.value = row.id
form.productId = row.productId
form.groupPrice = row.groupPrice
form.requiredMembers = row.requiredMembers
form.durationMinutes = row.durationMinutes
form.totalStock = row.totalStock
form.maxPerUser = row.maxPerUser
form.startTime = dayjs(row.startTime).format(TIME_FORMAT)
form.endTime = dayjs(row.endTime).format(TIME_FORMAT)
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (!validateTimeRange()) return
submitting.value = true
try {
const payload = {...form, productId: form.productId!}
if (editingId.value) {
await groupbuyingApi.update(editingId.value, payload)
ElMessage.success('更新成功')
} else {
await groupbuyingApi.create(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await reloadData()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
} finally {
submitting.value = false
}
})
}
const publishActivity = async (row: GroupBuying) => {
await ElMessageBox.confirm(`确定要发布活动吗?`, '发布确认', {type: 'warning'})
try {
await groupbuyingApi.update(row.id, {status: 1})
ElMessage.success('已发布')
await reloadData()
} catch (e: any) {
ElMessage.error(e.message || '发布失败')
}
}
const removeActivity = async (row: GroupBuying) => {
await ElMessageBox.confirm('确定要删除该拼团活动吗?', '删除确认', {type: 'warning'})
try {
await groupbuyingApi.delete(row.id)
ElMessage.success('删除成功')
await reloadData()
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
}
}
const handleSearch = () => {
pagination.page = 1
loadList()
}
const handleReset = () => {
query.status = ''
handleSearch()
}
const handlePageSizeChange = () => {
pagination.page = 1
loadList()
}
const reloadData = async () => {
await Promise.all([loadProducts(), loadList()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.page-actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value {
@apply text-3xl font-bold;
}
&__label {
@apply text-sm opacity-90 mt-2;
}
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns: 180px 100px 100px;
gap: 12px;
}
.product-cell {
display: flex;
align-items: center;
gap: 12px;
}
.product-image {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #d8cebf;
}
.product-name {
@apply font-medium text-slate-900;
}
.product-meta {
@apply text-xs text-slate-400 mt-1;
}
.table-footer {
@apply flex justify-end mt-4;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-card {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,537 @@
<template>
<div class="admin-monitor page-shell">
<div class="page-header">
<div>
<h2 class="page-title">系统监控</h2>
<p class="page-subtitle">复刻 JSP 监控页的指标卡服务状态性能趋势与实时日志</p>
</div>
<div class="page-actions">
<el-switch v-model="autoRefresh" active-text="自动刷新" inactive-text="手动" inline-prompt
@change="toggleAutoRefresh"/>
<el-button @click="clearLogs">清空日志</el-button>
<el-button type="primary" @click="refreshAll">
<el-icon>
<Refresh/>
</el-icon>
刷新全部
</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat blue">
<div class="mini-stat__value">{{ systemStatus.cpuUsage }}%</div>
<div class="mini-stat__label">CPU 使用率</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ systemStatus.memoryUsage }}%</div>
<div class="mini-stat__label">内存使用率</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ userStats.onlineUsers }}</div>
<div class="mini-stat__label">在线用户</div>
</div>
<div class="mini-stat purple">
<div class="mini-stat__value">{{ systemStatus.requestCountToday || 0 }}</div>
<div class="mini-stat__label">今日请求量</div>
</div>
</div>
<div class="content-grid">
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">服务状态</h3>
<p class="panel-subtitle">JSP 监控页中的服务检查模块</p>
</div>
<el-button text type="primary" @click="refreshAll">重新检查</el-button>
</div>
<div class="service-list">
<div class="service-row">
<div class="service-name"><span class="dot success"></span>应用服务</div>
<el-tag type="success">运行中</el-tag>
</div>
<div class="service-row">
<div class="service-name"><span :class="['dot', redisHealthy ? 'success' : 'danger']"></span>Redis 集群
</div>
<el-tag :type="redisHealthy ? 'success' : 'danger'">{{ redisHealthy ? '正常' : '异常' }}</el-tag>
</div>
<div class="service-row">
<div class="service-name"><span :class="['dot', mysqlHealthy ? 'success' : 'danger']"></span>MySQL 服务
</div>
<el-tag :type="mysqlHealthy ? 'success' : 'danger'">{{ mysqlHealthy ? '正常' : '异常' }}</el-tag>
</div>
</div>
</div>
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">性能趋势</h3>
<p class="panel-subtitle">CPU / 内存 / 磁盘曲线</p>
</div>
</div>
<div ref="chartRef" class="chart-container"></div>
</div>
</div>
<div class="content-grid">
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">Redis 节点</h3>
<p class="panel-subtitle">来源于 `/api/admin/monitor/redis`</p>
</div>
</div>
<el-table v-loading="loading" :data="redisNodes" stripe>
<el-table-column label="节点" min-width="180" prop="node"/>
<el-table-column label="状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '正常' ? 'success' : 'danger'">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="内存" prop="memory" width="110"/>
<el-table-column label="连接数" prop="connections" width="110"/>
</el-table>
</div>
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">活动监控</h3>
<p class="panel-subtitle">承接 JSP 中限时活动监控区域</p>
</div>
</div>
<div class="business-metrics">
<div class="business-item">
<span>活动总数</span>
<strong>{{ flashSaleStats.totalFlashSales }}</strong>
</div>
<div class="business-item">
<span>进行中</span>
<strong>{{ flashSaleStats.activeFlashSales }}</strong>
</div>
<div class="business-item">
<span>即将开始</span>
<strong>{{ flashSaleStats.upcomingFlashSales }}</strong>
</div>
<div class="business-item">
<span>已结束</span>
<strong>{{ flashSaleStats.endedFlashSales }}</strong>
</div>
</div>
<div class="alerts-box">
<el-alert
:closable="false"
:description="systemAlert.description"
:title="systemAlert.title"
:type="systemAlert.type"
show-icon
/>
</div>
</div>
</div>
<div class="panel-card">
<div class="panel-header">
<div>
<h3 class="panel-title">实时日志</h3>
<p class="panel-subtitle">模拟 JSP 监控页中的滚动日志面板</p>
</div>
</div>
<div class="log-list">
<div v-for="item in logs" :key="item.id" class="log-row">
<span class="log-time">{{ item.time }}</span>
<el-tag :type="item.level === 'error' ? 'danger' : item.level === 'warn' ? 'warning' : 'success'"
size="small">
{{ item.level.toUpperCase() }}
</el-tag>
<span class="log-message">{{ item.message }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {computed, nextTick, onMounted, onUnmounted, reactive, ref} from 'vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import {adminApi} from '@/api/modules/admin'
import type {AdminFlashSaleStats, AdminUserStats, MonitorSystemStatus, RedisNodeStatus} from '@/types/admin'
interface LogEntry {
id: number
time: string
level: 'info' | 'warn' | 'error'
message: string
}
const loading = ref(false)
const autoRefresh = ref(true)
const redisNodes = ref<RedisNodeStatus[]>([])
const logs = ref<LogEntry[]>([])
const systemStatus = reactive<MonitorSystemStatus>({
status: '未知',
cpuUsage: 0,
memoryUsage: 0,
diskUsage: 0,
})
const flashSaleStats = reactive<AdminFlashSaleStats>({
totalFlashSales: 0,
activeFlashSales: 0,
upcomingFlashSales: 0,
endedFlashSales: 0,
})
const userStats = reactive<AdminUserStats>({
totalUsers: 0,
activeUsers: 0,
newUsers: 0,
onlineUsers: 0,
})
const chartRef = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
let timer: number | null = null
const history = reactive({
time: [] as string[],
cpu: [] as number[],
memory: [] as number[],
disk: [] as number[],
})
const redisHealthy = computed(() => systemStatus.redisStatus === '正常' || (redisNodes.value.length > 0 && redisNodes.value.every((item) => item.status === '正常')))
const mysqlHealthy = computed(() => systemStatus.dbStatus === '正常')
const systemAlert = computed(() => {
if (systemStatus.cpuUsage > 85 || systemStatus.memoryUsage > 85) {
return {
type: 'warning' as const,
title: '资源占用偏高',
description: '请重点关注 CPU 或内存使用率,必要时扩容或检查热点接口。',
}
}
return {
type: 'success' as const,
title: '系统运行正常',
description: '当前各关键服务健康,未发现明显异常。',
}
})
const appendLog = (level: LogEntry['level'], message: string) => {
logs.value.unshift({
id: Date.now() + Math.random(),
time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
level,
message,
})
logs.value = logs.value.slice(0, 16)
}
const renderChart = () => {
if (!chartRef.value) return
if (!chart) {
chart = echarts.init(chartRef.value)
}
chart.setOption({
tooltip: {trigger: 'axis'},
color: ['#171715', '#5e5e58', '#9f9f99'],
legend: {top: 0},
grid: {left: 24, right: 24, top: 40, bottom: 24, containLabel: true},
xAxis: {type: 'category', data: history.time},
yAxis: {type: 'value', max: 100},
series: [
{name: 'CPU', type: 'line', smooth: true, data: history.cpu},
{name: '内存', type: 'line', smooth: true, data: history.memory},
{name: '磁盘', type: 'line', smooth: true, data: history.disk},
],
})
}
const pushHistory = () => {
history.time.push(dayjs().format('HH:mm:ss'))
history.cpu.push(systemStatus.cpuUsage)
history.memory.push(systemStatus.memoryUsage)
history.disk.push(systemStatus.diskUsage)
if (history.time.length > 12) {
history.time.shift()
history.cpu.shift()
history.memory.shift()
history.disk.shift()
}
}
const loadMetrics = async () => {
loading.value = true
try {
const [systemRes, redisRes, flashSaleRes, userRes] = await Promise.all([
adminApi.getSystemStatus(),
adminApi.getRedisStatus(),
adminApi.getFlashSaleStats(),
adminApi.getUserStats(),
])
Object.assign(systemStatus, systemRes.data)
Object.assign(flashSaleStats, flashSaleRes.data)
Object.assign(userStats, userRes.data)
redisNodes.value = redisRes.data
pushHistory()
await nextTick()
renderChart()
appendLog('info', `系统状态刷新完成CPU ${systemStatus.cpuUsage}% / 内存 ${systemStatus.memoryUsage}% / 今日请求 ${systemStatus.requestCountToday || 0}`)
if (!redisHealthy.value) {
appendLog('warn', '检测到 Redis 节点存在异常,请尽快排查')
}
} finally {
loading.value = false
}
}
const refreshAll = async () => {
await loadMetrics()
}
const clearLogs = () => {
logs.value = []
appendLog('info', '日志面板已清空')
}
const setupTimer = () => {
if (timer) {
window.clearInterval(timer)
timer = null
}
if (autoRefresh.value) {
timer = window.setInterval(() => {
loadMetrics()
}, 10000)
}
}
const toggleAutoRefresh = () => {
setupTimer()
appendLog('info', autoRefresh.value ? '已开启自动刷新' : '已切换为手动刷新')
}
const handleResize = () => {
chart?.resize()
}
onMounted(() => {
appendLog('info', '监控模块已启动')
loadMetrics()
setupTimer()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (timer) {
window.clearInterval(timer)
}
window.removeEventListener('resize', handleResize)
chart?.dispose()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.page-actions {
display: flex;
gap: 12px;
align-items: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value {
@apply text-3xl font-bold;
}
&__label {
@apply text-sm opacity-90 mt-2;
}
}
.content-grid {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 20px;
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.panel-header {
@apply flex items-center justify-between mb-4 gap-4;
}
.panel-title {
@apply text-lg font-semibold text-slate-900;
}
.panel-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.service-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.service-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
background: #f4ede4;
border-radius: 14px;
border: 1px solid #d8cebf;
}
.service-name {
display: flex;
align-items: center;
gap: 10px;
color: #334155;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot.success {
background: #171715;
}
.dot.danger {
background: #666666;
}
.chart-container {
height: 320px;
}
.business-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.business-item {
background: #f4ede4;
border-radius: 14px;
border: 1px solid #d8cebf;
padding: 16px;
display: flex;
justify-content: space-between;
color: #475569;
}
.business-item strong {
color: #0f172a;
}
.alerts-box {
@apply mt-5;
}
.log-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow: auto;
}
.log-row {
display: grid;
grid-template-columns: 160px 90px 1fr;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: #fffaf2;
color: #171715;
border-radius: 12px;
border: 1px solid #d8cebf;
}
.log-time {
color: #666666;
font-size: 12px;
}
.log-message {
word-break: break-word;
}
@media (max-width: 1024px) {
.stats-grid,
.content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.stats-grid,
.content-grid,
.business-metrics {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
.log-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,451 @@
<template>
<div class="admin-orders page-shell">
<div class="page-header">
<div>
<h2 class="page-title">订单管理</h2>
<p class="page-subtitle">延续 JSP 后台的统计卡片筛选表格与订单详情操作</p>
</div>
<div class="page-actions">
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
<el-button @click="exportCsv">
<el-icon>
<Download/>
</el-icon>
导出
</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat blue">
<div class="mini-stat__value">{{ stats.totalOrders }}</div>
<div class="mini-stat__label">订单总数</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.pendingOrders }}</div>
<div class="mini-stat__label">待处理</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ stats.paidOrders }}</div>
<div class="mini-stat__label">已支付</div>
</div>
<div class="mini-stat purple">
<div class="mini-stat__value">¥{{ formatCurrency(stats.totalAmount) }}</div>
<div class="mini-stat__label">成交总额</div>
</div>
</div>
<div class="panel-card filter-card">
<el-input v-model="query.keyword" clearable placeholder="搜索订单号 / 用户 / 商品" @keyup.enter="loadOrders">
<template #prefix>
<el-icon>
<Search/>
</el-icon>
</template>
</el-input>
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
<el-option label="待支付" value="1"/>
<el-option label="已支付" value="2"/>
<el-option label="已发货" value="3"/>
<el-option label="已完成" value="4"/>
<el-option label="已取消" value="5"/>
<el-option label="退货中" value="6"/>
<el-option label="已退货" value="7"/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="displayOrders" stripe>
<el-table-column label="订单号" min-width="120" prop="orderNo"/>
<el-table-column label="用户" min-width="100" prop="username"/>
<el-table-column label="商品" min-width="160" prop="productName" show-overflow-tooltip/>
<el-table-column label="数量" prop="quantity" width="80"/>
<el-table-column label="金额" prop="totalAmount" width="110">
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getOrderTypeTag(row.orderType)">{{ getOrderTypeText(row.orderType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170" prop="createdAt">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="220">
<template #default="{ row }">
<el-button text type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button v-if="row.status === 'PAID'" text type="success" @click="shipOrder(row)">发货</el-button>
<el-button v-if="row.status === 'PENDING' || row.status === 'PAID'" text type="danger"
@click="cancelOrder(row)">取消
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadOrders"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<el-dialog v-model="detailVisible" title="订单详情" width="760px">
<div v-if="currentOrder" class="detail-shell">
<div class="detail-header">
<div>
<h3>{{ currentOrder.orderNo }}</h3>
<p>{{ currentOrder.username }} · {{ formatTime(currentOrder.createdAt) }}</p>
</div>
<el-tag :type="getStatusType(currentOrder.status)">{{ getStatusText(currentOrder.status) }}</el-tag>
</div>
<div v-for="item in currentOrder.items" :key="item.id" class="item-card">
<SafeImage :alt="item.productName" :src="item.productImage" img-class="item-image"
wrapper-class="item-image"/>
<div class="item-info">
<div class="item-name">{{ item.productName }}</div>
<div class="item-meta">¥{{ formatCurrency(item.price) }} × {{ item.quantity }}</div>
</div>
<div class="item-total">¥{{ formatCurrency(item.subtotal) }}</div>
</div>
<div class="detail-grid">
<div><span>订单编号</span>{{ currentOrder.orderNo }}</div>
<div><span>支付方式</span>{{ currentOrder.paymentMethod || '未支付' }}</div>
<div><span>创建时间</span>{{ formatTime(currentOrder.createdAt) }}</div>
<div><span>更新时间</span>{{ formatTime(currentOrder.updatedAt) }}</div>
<div><span>实付金额</span>¥{{ formatCurrency(currentOrder.paymentAmount) }}</div>
<div><span>订单状态</span>{{ getStatusText(currentOrder.status) }}</div>
<div v-if="currentOrder.paidAt"><span>支付时间</span>{{ formatTime(currentOrder.paidAt) }}</div>
<div v-if="currentOrder.shippedAt"><span>发货时间</span>{{ formatTime(currentOrder.shippedAt) }}</div>
<div v-if="currentOrder.completedAt"><span>完成时间</span>{{ formatTime(currentOrder.completedAt) }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {computed, onMounted, reactive, ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import dayjs from 'dayjs'
import {adminApi} from '@/api/modules/admin'
import {orderApi} from '@/api/modules/order'
import type {Order} from '@/types/api'
import SafeImage from '@/components/common/SafeImage.vue'
import type {AdminOrderRow, AdminOrderStats} from '@/types/admin'
const loading = ref(false)
const detailVisible = ref(false)
const orders = ref<AdminOrderRow[]>([])
const currentOrder = ref<Order | null>(null)
const query = reactive({
keyword: '',
status: '',
})
const pagination = reactive({
page: 1,
size: 10,
total: 0,
})
const stats = reactive<AdminOrderStats>({
totalOrders: 0,
paidOrders: 0,
pendingOrders: 0,
completedOrders: 0,
cancelledOrders: 0,
totalAmount: 0,
})
const displayOrders = computed(() => {
if (!query.keyword) return orders.value
const keyword = query.keyword.toLowerCase()
return orders.value.filter((item) => [item.orderNo, item.username, item.productName].some((field) => field.toLowerCase().includes(keyword)))
})
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
const getStatusText = (status: string) => {
const map: Record<string, string> = {
PENDING: '待支付',
PAID: '已支付',
SHIPPED: '已发货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退货中',
REFUNDED: '已退货',
}
return map[status] || status
}
const getStatusType = (status: string) => {
const map: Record<string, string> = {
PENDING: 'warning',
PAID: 'primary',
SHIPPED: 'success',
COMPLETED: 'success',
CANCELLED: 'info',
REFUNDING: 'warning',
REFUNDED: 'danger',
}
return map[status] || 'info'
}
const getOrderTypeText = (orderType: string) => {
const map: Record<string, string> = {NORMAL: '普通订单', FLASH_SALE: '限时订单', GROUP_BUYING: '拼团订单'}
return map[orderType] || '普通订单'
}
const getOrderTypeTag = (orderType: string) => {
const map: Record<string, string> = {NORMAL: 'info', FLASH_SALE: 'danger', GROUP_BUYING: 'success'}
return map[orderType] || 'info'
}
const loadStats = async () => {
const res = await adminApi.getOrderStats()
Object.assign(stats, res.data)
}
const loadOrders = async () => {
loading.value = true
try {
const res = await adminApi.getOrders({
page: pagination.page,
size: pagination.size,
status: query.status || undefined,
keyword: query.keyword || undefined,
})
orders.value = res.data.orders
pagination.total = res.data.total
} finally {
loading.value = false
}
}
const openDetail = async (id: number) => {
const res = await orderApi.getDetail(id)
currentOrder.value = res.data
detailVisible.value = true
}
const shipOrder = async (row: AdminOrderRow) => {
await ElMessageBox.confirm(`确定将订单 ${row.orderNo} 标记为已发货吗?`, '发货确认', {type: 'warning'})
await orderApi.ship(row.id)
ElMessage.success('订单已发货')
await reloadData()
}
const cancelOrder = async (row: AdminOrderRow) => {
await ElMessageBox.confirm(`确定取消订单 ${row.orderNo} 吗?`, '取消确认', {type: 'warning'})
await orderApi.updateStatus(row.id, 5, '管理后台取消订单')
ElMessage.success('订单已取消')
await reloadData()
}
const handleSearch = () => {
pagination.page = 1
loadOrders()
}
const handleReset = () => {
query.keyword = ''
query.status = ''
handleSearch()
}
const handlePageSizeChange = () => {
pagination.page = 1
loadOrders()
}
const exportCsv = () => {
const rows = displayOrders.value.map((item) => [item.orderNo, item.username, item.productName, item.quantity, item.totalAmount, getStatusText(item.status), formatTime(item.createdAt)])
const header = ['订单号', '用户', '商品', '数量', '金额', '状态', '创建时间']
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
const blob = new Blob([`\ufeff${csv}`], {type: 'text/csv;charset=utf-8;'})
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `orders-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
link.click()
URL.revokeObjectURL(link.href)
}
const migrateItems = async () => {
const res = await adminApi.migrateLegacyOrderItems()
ElMessage.success('迁移完成:迁移 ' + res.data.migrated + ' 条,跳过 ' + res.data.skipped + ' 条')
reloadData()
}
const reloadData = async () => {
await Promise.all([loadStats(), loadOrders()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.page-actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value {
@apply text-3xl font-bold;
}
&__label {
@apply text-sm opacity-90 mt-2;
}
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns: 1.4fr 180px 100px 100px;
gap: 12px;
}
.table-footer {
@apply flex justify-end mt-4;
}
.detail-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.detail-header h3 {
@apply text-xl font-semibold text-slate-900;
}
.detail-header p {
@apply text-sm text-slate-500 mt-1;
}
.item-card {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 16px;
align-items: center;
padding: 16px;
background: #f4ede4;
border-radius: 16px;
border: 1px solid #d8cebf;
}
.item-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 14px;
}
.item-name {
@apply font-medium text-slate-900;
}
.item-meta {
@apply text-sm text-slate-500 mt-1;
}
.item-total {
@apply text-lg font-semibold;
color: #171715;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-grid span {
color: #94a3b8;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-card {
grid-template-columns: 1fr 1fr;
}
.item-card {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,511 @@
<template>
<div class="admin-products page-shell">
<div class="page-header">
<div>
<h2 class="page-title">商品管理</h2>
<p class="page-subtitle">复刻 JSP 后台的筛选列表查看与增改删流程</p>
</div>
<div class="page-actions">
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
<el-button type="primary" @click="openCreateDialog">
<el-icon>
<Plus/>
</el-icon>
添加商品
</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat blue">
<div class="mini-stat__value">{{ stats.totalProducts }}</div>
<div class="mini-stat__label">商品总数</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ stats.activeProducts }}</div>
<div class="mini-stat__label">上架商品</div>
</div>
<div class="mini-stat gray">
<div class="mini-stat__value">{{ stats.inactiveProducts }}</div>
<div class="mini-stat__label">下架商品</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.lowStockProducts }}</div>
<div class="mini-stat__label">低库存商品</div>
</div>
</div>
<div class="panel-card filter-card">
<el-input
v-model="query.keyword"
clearable
placeholder="搜索商品名称"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon>
<Search/>
</el-icon>
</template>
</el-input>
<el-select v-model="query.category" clearable placeholder="全部分类" @change="handleSearch">
<el-option v-for="item in categories" :key="item" :label="item" :value="item"/>
</el-select>
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
<el-option :value="1" label="上架"/>
<el-option :value="0" label="下架"/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="products" stripe>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="商品图片" width="100">
<template #default="{ row }">
<SafeImage :alt="row.name" :src="row.imageUrl" img-class="product-image" wrapper-class="product-image"/>
</template>
</el-table-column>
<el-table-column label="商品名称" min-width="180" prop="name" show-overflow-tooltip/>
<el-table-column label="商品描述" min-width="220" prop="description" show-overflow-tooltip/>
<el-table-column label="分类" prop="category" width="120"/>
<el-table-column label="价格" prop="price" width="110">
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
</el-table-column>
<el-table-column label="库存" prop="stock" width="90">
<template #default="{ row }">
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '上架' : '下架' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170" prop="createdAt">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="240">
<template #default="{ row }">
<el-button text type="primary" @click="openDetail(row.id)">查看</el-button>
<el-button text type="primary" @click="openEditDialog(row.id)">编辑</el-button>
<el-button text type="danger" @click="removeProduct(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadProducts"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '添加商品' : '编辑商品'" width="720px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="商品名称" prop="name">
<el-input v-model="form.name" placeholder="请输入商品名称"/>
</el-form-item>
<el-form-item label="商品分类" prop="category">
<el-select v-model="form.category" allow-create class="w-full" default-first-option filterable
placeholder="请选择分类">
<el-option v-for="item in categories" :key="item" :label="item" :value="item"/>
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="价格" prop="price">
<el-input-number v-model="form.price" :min="0.01" :precision="2" class="w-full"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="库存" prop="stock">
<el-input-number v-model="form.stock" :min="0" class="w-full"/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">上架</el-radio>
<el-radio :label="0">下架</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="商品图片" prop="imageUrl">
<ImageUpload v-model="form.imageUrl"/>
</el-form-item>
<el-form-item label="商品描述" prop="description">
<el-input v-model="form.description" :rows="4" maxlength="500" show-word-limit type="textarea"/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button :loading="saving" type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" title="商品详情" width="760px">
<div v-if="detail" class="detail-layout">
<SafeImage :alt="detail.name" :src="detail.imageUrl" img-class="detail-image" wrapper-class="detail-image"/>
<div class="detail-content">
<h3>{{ detail.name }}</h3>
<div class="detail-price">¥{{ formatCurrency(detail.price) }}</div>
<div class="detail-grid">
<div><span>分类</span>{{ detail.category }}</div>
<div><span>库存</span>{{ detail.stock }}</div>
<div><span>状态</span>{{ detail.status === 1 ? '上架' : '下架' }}</div>
<div><span>创建时间</span>{{ formatTime(detail.createdAt) }}</div>
<div><span>更新时间</span>{{ formatTime(detail.updatedAt || detail.createdAt) }}</div>
<div><span>总销量</span>{{ detail.totalSales || 0 }}</div>
<div><span>浏览次数</span>{{ detail.viewCount || 0 }}</div>
<div><span>总收入</span>¥{{ formatCurrency(detail.totalRevenue || 0) }}</div>
<div><span>评分</span>{{ Number(detail.rating || 0).toFixed(1) }}</div>
</div>
<div class="detail-description">{{ detail.description || '暂无描述' }}</div>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button v-if="detail" type="primary" @click="openEditDialog(detail.id)">编辑商品</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {onMounted, reactive, ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import type {FormInstance, FormRules} from 'element-plus'
import dayjs from 'dayjs'
import ImageUpload from '@/components/common/ImageUpload.vue'
import SafeImage from '@/components/common/SafeImage.vue'
import {adminApi} from '@/api/modules/admin'
import {productApi} from '@/api/modules/product'
import type {AdminProductRow, AdminProductStats} from '@/types/admin'
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const detailVisible = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const detail = ref<AdminProductRow | null>(null)
const products = ref<AdminProductRow[]>([])
const categories = ref<string[]>([])
const query = reactive({
keyword: '',
category: '',
status: '' as number | '',
})
const pagination = reactive({
page: 1,
size: 10,
total: 0,
})
const stats = reactive<AdminProductStats>({
totalProducts: 0,
activeProducts: 0,
inactiveProducts: 0,
lowStockProducts: 0,
})
const form = reactive({
id: 0,
name: '',
description: '',
category: '',
price: 0.01,
stock: 0,
status: 1,
imageUrl: '',
})
const rules: FormRules = {
name: [{required: true, message: '请输入商品名称', trigger: 'blur'}],
category: [{required: true, message: '请选择商品分类', trigger: 'change'}],
price: [{required: true, message: '请输入商品价格', trigger: 'change'}],
}
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
const resetForm = () => {
form.id = 0
form.name = ''
form.description = ''
form.category = ''
form.price = 0.01
form.stock = 0
form.status = 1
form.imageUrl = ''
}
const loadStats = async () => {
const res = await adminApi.getProductStats()
Object.assign(stats, res.data)
}
const loadCategories = async () => {
const res = await productApi.getCategories()
categories.value = res.success ? res.data : []
}
const loadProducts = async () => {
loading.value = true
try {
const res = await adminApi.getProducts({
page: pagination.page,
size: pagination.size,
keyword: query.keyword || undefined,
status: query.status === '' ? undefined : query.status,
category: query.category || undefined,
})
products.value = res.data.products
pagination.total = res.data.total
} finally {
loading.value = false
}
}
const loadProductDetail = async (id: number) => {
const res = await adminApi.getProduct(id)
return res.data
}
const openCreateDialog = () => {
resetForm()
formMode.value = 'create'
formVisible.value = true
}
const openEditDialog = async (id: number) => {
const product = await loadProductDetail(id)
detail.value = product
formMode.value = 'edit'
form.id = product.id
form.name = product.name
form.description = product.description
form.category = product.category
form.price = product.price
form.stock = product.stock
form.status = product.status
form.imageUrl = product.imageUrl
formVisible.value = true
detailVisible.value = false
}
const openDetail = async (id: number) => {
detail.value = await loadProductDetail(id)
detailVisible.value = true
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
saving.value = true
try {
const payload = {
name: form.name,
description: form.description,
category: form.category,
price: form.price,
stock: form.stock,
status: form.status,
imageUrl: form.imageUrl,
}
if (formMode.value === 'create') {
await adminApi.createProduct(payload)
ElMessage.success('商品添加成功')
} else {
await adminApi.updateProduct(form.id, payload)
ElMessage.success('商品更新成功')
}
formVisible.value = false
await reloadData()
} finally {
saving.value = false
}
})
}
const removeProduct = async (row: AdminProductRow) => {
await ElMessageBox.confirm(`确定删除商品“${row.name}”吗?`, '删除确认', {
type: 'warning',
})
await adminApi.deleteProduct(row.id)
ElMessage.success('商品已删除')
await reloadData()
}
const handleSearch = () => {
pagination.page = 1
loadProducts()
}
const handleReset = () => {
query.keyword = ''
query.category = ''
query.status = ''
handleSearch()
}
const handlePageSizeChange = () => {
pagination.page = 1
loadProducts()
}
const reloadData = async () => {
await Promise.all([loadStats(), loadCategories(), loadProducts()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.page-actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value {
@apply text-3xl font-bold;
}
&__label {
@apply text-sm opacity-90 mt-2;
}
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns: 1.4fr 180px 180px 100px 100px;
gap: 12px;
}
.product-image {
width: 54px;
height: 54px;
object-fit: cover;
border-radius: 10px;
border: 1px solid #e2e8f0;
}
.table-footer {
@apply flex justify-end mt-4;
}
.detail-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 20px;
}
.detail-image {
width: 220px;
height: 220px;
object-fit: cover;
border-radius: 16px;
border: 1px solid #d8cebf;
}
.detail-content h3 {
@apply text-2xl font-semibold text-slate-900;
}
.detail-price {
@apply text-3xl font-bold mt-3 mb-4;
color: #171715;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
color: #475569;
}
.detail-grid span {
color: #94a3b8;
}
.detail-description {
@apply mt-5 text-sm leading-6 text-slate-600 rounded-xl p-4;
background: #f4ede4;
border: 1px solid #d8cebf;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-card {
grid-template-columns: 1fr 1fr;
}
.detail-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h2 class="page-title">退货管理</h2>
<p class="page-subtitle">处理用户退货申请审核和退款</p>
</div>
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
</div>
<div class="stats-grid">
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.pendingCount }}</div>
<div class="mini-stat__label">待审核</div>
</div>
<div class="mini-stat blue">
<div class="mini-stat__value">{{ stats.approvedCount }}</div>
<div class="mini-stat__label">已同意</div>
</div>
<div class="mini-stat purple">
<div class="mini-stat__value">{{ stats.returningCount }}</div>
<div class="mini-stat__label">退货中</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ stats.completedCount }}</div>
<div class="mini-stat__label">已完成</div>
</div>
</div>
<div class="panel-card filter-card">
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width: 150px" @change="loadReturns">
<el-option :value="undefined" label="全部"/>
<el-option :value="1" label="待审核"/>
<el-option :value="2" label="已同意"/>
<el-option :value="3" label="退货中"/>
<el-option :value="4" label="已完成"/>
<el-option :value="5" label="已拒绝"/>
<el-option :value="6" label="已取消"/>
</el-select>
<el-button type="primary" @click="loadReturns">查询</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="returns" stripe>
<el-table-column label="退货单号" min-width="140" prop="returnNo"/>
<el-table-column label="订单号" min-width="140" prop="orderNo"/>
<el-table-column label="用户" prop="username" width="100"/>
<el-table-column label="商品" min-width="160" prop="productName" show-overflow-tooltip/>
<el-table-column label="退款金额" prop="refundAmount" width="100">
<template #default="{ row }">&yen;{{ row.refundAmount }}</template>
</el-table-column>
<el-table-column label="退货原因" min-width="120" prop="reason" show-overflow-tooltip/>
<el-table-column label="状态" prop="statusText" width="90">
<template #default="{ row }">
<el-tag :type="getReturnStatusType(row.status)" size="small">{{ row.statusText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="申请时间" min-width="170" prop="createdAt">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status === 'PENDING'" text type="primary" @click="openReviewDialog(row)">审核
</el-button>
<el-button v-if="row.status === 'RETURNING'" text type="success" @click="handleComplete(row)">确认退款
</el-button>
<el-button text @click="openDetailDialog(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination v-model:current-page="page" v-model:page-size="size" :page-sizes="[10,20,50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" @current-change="loadReturns"
@size-change="loadReturns"/>
</div>
</div>
<!-- 审核弹窗 -->
<el-dialog v-model="reviewVisible" title="退货审核" width="500px">
<div v-if="currentReturn" class="space-y-4">
<div class="text-sm"><span class="text-gray-500">退货单号</span>{{ currentReturn.returnNo }}</div>
<div class="text-sm"><span class="text-gray-500">用户</span>{{ currentReturn.username }}</div>
<div class="text-sm"><span class="text-gray-500">退款金额</span><span class="text-red-500 font-semibold">&yen;{{
currentReturn.refundAmount
}}</span></div>
<div class="text-sm"><span class="text-gray-500">退货原因</span>{{ currentReturn.reason }}</div>
<div v-if="currentReturn.description" class="text-sm"><span
class="text-gray-500">详细描述</span>{{ currentReturn.description }}
</div>
<el-divider/>
<el-radio-group v-model="reviewForm.status">
<el-radio :label="2">同意退货</el-radio>
<el-radio :label="5">拒绝退货</el-radio>
</el-radio-group>
<el-input v-if="reviewForm.status === 5" v-model="reviewForm.rejectReason" :rows="3" placeholder="请输入拒绝原因"
type="textarea"/>
<el-input v-model="reviewForm.adminRemark" :rows="2" placeholder="管理员备注(选填)" type="textarea"/>
</div>
<template #footer>
<el-button @click="reviewVisible = false">取消</el-button>
<el-button :disabled="!reviewForm.status" type="primary" @click="submitReview">确认</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="退货详情" width="600px">
<div v-if="currentReturn" class="space-y-3 text-sm">
<div><span class="text-gray-500">退货单号</span>{{ currentReturn.returnNo }}</div>
<div><span class="text-gray-500">订单号</span>{{ currentReturn.orderNo }}</div>
<div><span class="text-gray-500">用户</span>{{ currentReturn.username }}</div>
<div><span class="text-gray-500">商品</span>{{ currentReturn.productName }}</div>
<div><span class="text-gray-500">退款金额</span><span
class="text-red-500 font-semibold">&yen;{{ currentReturn.refundAmount }}</span></div>
<div><span class="text-gray-500">退货原因</span>{{ currentReturn.reason }}</div>
<div v-if="currentReturn.description"><span class="text-gray-500">详细描述</span>{{
currentReturn.description
}}
</div>
<div><span class="text-gray-500">状态</span>
<el-tag :type="getReturnStatusType(currentReturn.status)" size="small">{{ currentReturn.statusText }}</el-tag>
</div>
<div v-if="currentReturn.rejectReason"><span class="text-gray-500">拒绝原因</span><span
class="text-red-500">{{ currentReturn.rejectReason }}</span></div>
<div v-if="currentReturn.returnTracking"><span
class="text-gray-500">物流单号</span>{{ currentReturn.returnTracking }}
</div>
<div v-if="currentReturn.adminRemark"><span class="text-gray-500">管理员备注</span>{{
currentReturn.adminRemark
}}
</div>
<div><span class="text-gray-500">申请时间</span>{{ formatTime(currentReturn.createdAt) }}</div>
<div v-if="currentReturn.reviewedAt"><span
class="text-gray-500">审核时间</span>{{ formatTime(currentReturn.reviewedAt) }}
</div>
<div v-if="currentReturn.shippedAt"><span
class="text-gray-500">寄出时间</span>{{ formatTime(currentReturn.shippedAt) }}
</div>
<div v-if="currentReturn.completedAt"><span
class="text-gray-500">完成时间</span>{{ formatTime(currentReturn.completedAt) }}
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {onMounted, reactive, ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {returnApi} from '@/api/modules/return'
import {normalizeOrderReturn} from '@/utils/normalizers'
import type {OrderReturn} from '@/types/api'
import dayjs from 'dayjs'
const loading = ref(false)
const page = ref(1)
const size = ref(10)
const total = ref(0)
const returns = ref<OrderReturn[]>([])
const filters = reactive<{ status: number | undefined }>({status: undefined})
const stats = reactive({
pendingCount: 0,
approvedCount: 0,
returningCount: 0,
completedCount: 0,
rejectedCount: 0,
cancelledCount: 0,
totalCount: 0
})
const reviewVisible = ref(false)
const detailVisible = ref(false)
const currentReturn = ref<OrderReturn | null>(null)
const reviewForm = reactive({status: 0 as number, rejectReason: '', adminRemark: ''})
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getReturnStatusType = (status: string) => ({
PENDING: 'warning',
APPROVED: 'primary',
RETURNING: 'primary',
COMPLETED: 'success',
REJECTED: 'danger',
CANCELLED: 'info'
}[status] || 'info')
const loadStats = async () => {
try {
const res = await returnApi.getStatistics()
if (res.success) Object.assign(stats, res.data)
} catch (error) {
console.error('加载统计失败:', error)
}
}
const loadReturns = async () => {
loading.value = true
try {
const res = await returnApi.getAll({
status: filters.status,
page: page.value - 1,
size: size.value,
})
if (res.success) {
returns.value = (res.data.content || []).map((item: any) => normalizeOrderReturn(item))
total.value = res.data.totalElements || 0
}
} finally {
loading.value = false
}
}
const openReviewDialog = (row: OrderReturn) => {
currentReturn.value = row
reviewForm.status = 0
reviewForm.rejectReason = ''
reviewForm.adminRemark = ''
reviewVisible.value = true
}
const openDetailDialog = (row: OrderReturn) => {
currentReturn.value = row
detailVisible.value = true
}
const submitReview = async () => {
if (!currentReturn.value || !reviewForm.status) return
if (reviewForm.status === 5 && !reviewForm.rejectReason.trim()) {
ElMessage.warning('请输入拒绝原因')
return
}
try {
await returnApi.adminReview(currentReturn.value.id, {
status: reviewForm.status,
rejectReason: reviewForm.status === 5 ? reviewForm.rejectReason : undefined,
adminRemark: reviewForm.adminRemark || undefined,
})
ElMessage.success('审核完成')
reviewVisible.value = false
reloadData()
} catch (error) {
console.error('审核失败:', error)
}
}
const handleComplete = async (row: OrderReturn) => {
await ElMessageBox.confirm(`确认退款 ¥${row.refundAmount} 给用户 ${row.username}`, '确认退款', {
confirmButtonText: '确认退款',
cancelButtonText: '取消',
type: 'warning'
})
try {
await returnApi.adminComplete(row.id)
ElMessage.success('退款已完成')
reloadData()
} catch (error) {
console.error('确认退款失败:', error)
}
}
const reloadData = async () => {
await Promise.all([loadStats(), loadReturns()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.stats-grid {
display: grid;
grid-template-columns:repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.mini-stat__value {
@apply text-3xl font-bold;
}
.mini-stat__label {
@apply text-sm opacity-90 mt-2;
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: flex;
gap: 12px;
align-items: center;
}
.table-footer {
@apply flex justify-end mt-4;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div class="page-shell">
<div class="page-header">
<div>
<h2 class="page-title">评价管理</h2>
<p class="page-subtitle">查看隐藏和回复用户评价</p>
</div>
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
</div>
<div class="stats-grid">
<div class="mini-stat blue">
<div class="mini-stat__value">{{ stats.totalReviews }}</div>
<div class="mini-stat__label">评价总数</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ stats.todayReviews }}</div>
<div class="mini-stat__label">今日新增</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.averageRating.toFixed(1) }}</div>
<div class="mini-stat__label">平均评分</div>
</div>
<div class="mini-stat purple">
<div class="mini-stat__value">{{ stats.fiveStarReviews }}</div>
<div class="mini-stat__label">五星评价</div>
</div>
</div>
<div class="panel-card filter-card">
<el-input v-model="keyword" clearable placeholder="搜索用户 / 商品 / 评价内容" @keyup.enter="loadReviews"/>
<el-button type="primary" @click="loadReviews">搜索</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="reviews" stripe>
<el-table-column label="商品" min-width="160" prop="productName" show-overflow-tooltip/>
<el-table-column label="用户" prop="username" width="120"/>
<el-table-column label="评分" prop="rating" width="120">
<template #default="{ row }">
<el-rate :model-value="row.rating" disabled/>
</template>
</el-table-column>
<el-table-column label="评价内容" min-width="240" prop="content" show-overflow-tooltip/>
<el-table-column label="状态" prop="statusText" width="90"/>
<el-table-column label="回复" min-width="200" prop="adminReply" show-overflow-tooltip/>
<el-table-column label="时间" min-width="170" prop="createdAt"/>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button text type="primary" @click="openReply(row)">回复</el-button>
<el-button :type="row.status === 1 ? 'warning' : 'success'" text @click="toggleStatus(row)">
{{ row.status === 1 ? '隐藏' : '显示' }}
</el-button>
<el-button text type="danger" @click="removeReview(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination v-model:current-page="page" v-model:page-size="size" :page-sizes="[10,20,50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" @current-change="loadReviews"
@size-change="loadReviews"/>
</div>
</div>
<el-dialog v-model="replyVisible" title="评价回复" width="600px">
<el-input v-model="replyText" :rows="5" maxlength="500" placeholder="请输入管理员回复" show-word-limit
type="textarea"/>
<template #footer>
<el-button @click="replyVisible = false">取消</el-button>
<el-button type="primary" @click="submitReply">保存回复</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {onMounted, reactive, ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {adminApi} from '@/api/modules/admin'
import type {AdminReviewRow, AdminReviewStats} from '@/types/admin'
const loading = ref(false)
const keyword = ref('')
const page = ref(1)
const size = ref(10)
const total = ref(0)
const reviews = ref<AdminReviewRow[]>([])
const stats = reactive<AdminReviewStats>({totalReviews: 0, todayReviews: 0, averageRating: 0, fiveStarReviews: 0})
const replyVisible = ref(false)
const currentReviewId = ref<number | null>(null)
const replyText = ref('')
const loadStats = async () => {
const res = await adminApi.getReviewStats()
Object.assign(stats, res.data)
}
const loadReviews = async () => {
loading.value = true
try {
const res = await adminApi.getReviews({page: page.value, size: size.value, keyword: keyword.value || undefined})
reviews.value = res.data.reviews
total.value = res.data.total
} finally {
loading.value = false
}
}
const openReply = (row: AdminReviewRow) => {
currentReviewId.value = row.id;
replyText.value = row.adminReply || '';
replyVisible.value = true
}
const submitReply = async () => {
if (!currentReviewId.value) return
await adminApi.updateReview(currentReviewId.value, {adminReply: replyText.value, status: 1})
ElMessage.success('回复已保存')
replyVisible.value = false
loadReviews()
}
const toggleStatus = async (row: AdminReviewRow) => {
await adminApi.updateReview(row.id, {status: row.status === 1 ? 0 : 1})
ElMessage.success('状态已更新')
loadStats();
loadReviews()
}
const removeReview = async (id: number) => {
await ElMessageBox.confirm('确定删除该评价吗?', '提示', {type: 'warning'})
await adminApi.deleteReview(id)
ElMessage.success('删除成功')
loadStats();
loadReviews()
}
const reloadData = async () => {
await Promise.all([loadStats(), loadReviews()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.stats-grid {
display: grid;
grid-template-columns:repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.mini-stat__value {
@apply text-3xl font-bold;
}
.mini-stat__label {
@apply text-sm opacity-90 mt-2;
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns:1fr 100px;
gap: 12px;
}
.table-footer {
@apply flex justify-end mt-4;
}
</style>

View File

@@ -0,0 +1,316 @@
<template>
<div class="admin-users page-shell">
<div class="page-header">
<div>
<h2 class="page-title">用户管理</h2>
<p class="page-subtitle">对应 JSP 的用户统计筛选检索和分页列表</p>
</div>
<div class="page-actions">
<el-button @click="reloadData">
<el-icon>
<Refresh/>
</el-icon>
刷新
</el-button>
<el-button @click="exportCsv">
<el-icon>
<Download/>
</el-icon>
导出
</el-button>
</div>
</div>
<div class="stats-grid">
<div class="mini-stat blue">
<div class="mini-stat__value">{{ stats.totalUsers }}</div>
<div class="mini-stat__label">总用户数</div>
</div>
<div class="mini-stat green">
<div class="mini-stat__value">{{ stats.activeUsers }}</div>
<div class="mini-stat__label">活跃用户</div>
</div>
<div class="mini-stat orange">
<div class="mini-stat__value">{{ stats.newUsers }}</div>
<div class="mini-stat__label">今日新增</div>
</div>
<div class="mini-stat purple">
<div class="mini-stat__value">{{ stats.onlineUsers }}</div>
<div class="mini-stat__label">在线用户</div>
</div>
</div>
<div class="panel-card filter-card">
<el-input v-model="query.keyword" clearable placeholder="搜索用户名 / 邮箱 / 手机号" @keyup.enter="handleSearch">
<template #prefix>
<el-icon>
<Search/>
</el-icon>
</template>
</el-input>
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
<el-option :value="1" label="正常"/>
<el-option :value="0" label="禁用"/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="panel-card">
<el-table v-loading="loading" :data="users" stripe>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="用户名" min-width="120" prop="username"/>
<el-table-column label="邮箱" min-width="180" prop="email" show-overflow-tooltip/>
<el-table-column label="手机号" min-width="130" prop="phone"/>
<el-table-column label="角色" prop="role" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'">{{
row.role === 'ADMIN' ? '管理员' : '普通用户'
}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.statusText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="在线" width="100">
<template #default="{ row }">
<el-tag :type="row.isOnline ? 'success' : 'info'">{{ row.isOnline ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="注册时间" min-width="170" prop="createdAt">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170" prop="lastLogin">
<template #default="{ row }">{{ row.lastLogin ? formatTime(row.lastLogin) : '-' }}</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="150">
<template #default="{ row }">
<el-button text type="primary" @click="viewUser(row)">查看</el-button>
<el-button v-if="row.role !== 'ADMIN'" text type="danger" @click="removeUser(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadUsers"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<el-dialog v-model="detailVisible" title="用户详情" width="620px">
<div v-if="currentUser" class="detail-grid">
<div><span>用户ID</span>{{ currentUser.id }}</div>
<div><span>用户名</span>{{ currentUser.username }}</div>
<div><span>邮箱</span>{{ currentUser.email || '-' }}</div>
<div><span>手机号</span>{{ currentUser.phone || '-' }}</div>
<div><span>角色</span>{{ currentUser.role === 'ADMIN' ? '管理员' : '普通用户' }}</div>
<div><span>状态</span>{{ currentUser.statusText }}</div>
<div><span>在线状态</span>{{ currentUser.isOnline ? '在线' : '离线' }}</div>
<div><span>注册时间</span>{{ formatTime(currentUser.createdAt) }}</div>
<div v-if="currentUser.lastLogin"><span>最后登录</span>{{ formatTime(currentUser.lastLogin) }}</div>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {onMounted, reactive, ref} from 'vue'
import dayjs from 'dayjs'
import {ElMessage, ElMessageBox} from 'element-plus'
import {adminApi} from '@/api/modules/admin'
import type {AdminUserRow, AdminUserStats} from '@/types/admin'
const loading = ref(false)
const detailVisible = ref(false)
const users = ref<AdminUserRow[]>([])
const currentUser = ref<AdminUserRow | null>(null)
const query = reactive({
keyword: '',
status: '' as number | '',
})
const pagination = reactive({
page: 1,
size: 10,
total: 0,
})
const stats = reactive<AdminUserStats>({
totalUsers: 0,
activeUsers: 0,
newUsers: 0,
onlineUsers: 0,
})
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
const loadStats = async () => {
const res = await adminApi.getUserStats()
Object.assign(stats, res.data)
}
const loadUsers = async () => {
loading.value = true
try {
const res = await adminApi.getUsers({
page: pagination.page,
size: pagination.size,
keyword: query.keyword || undefined,
status: query.status === '' ? undefined : query.status,
})
users.value = res.data.users
pagination.total = res.data.total
} finally {
loading.value = false
}
}
const viewUser = (row: AdminUserRow) => {
currentUser.value = row
detailVisible.value = true
}
const removeUser = async (row: AdminUserRow) => {
await ElMessageBox.confirm(`确定删除用户“${row.username}”吗?该操作会同步清理该用户的订单、拼团、评价、收藏等数据。`, '删除确认', {type: 'warning'})
try {
await adminApi.deleteUser(row.id)
ElMessage.success('用户已删除')
await reloadData()
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
}
}
const handleSearch = () => {
pagination.page = 1
loadUsers()
}
const handleReset = () => {
query.keyword = ''
query.status = ''
handleSearch()
}
const handlePageSizeChange = () => {
pagination.page = 1
loadUsers()
}
const exportCsv = () => {
const rows = users.value.map((item) => [item.id, item.username, item.email, item.phone, item.role, item.statusText, item.isOnline ? '在线' : '离线', formatTime(item.createdAt), item.lastLogin ? formatTime(item.lastLogin) : ''])
const header = ['ID', '用户名', '邮箱', '手机号', '角色', '状态', '在线', '注册时间', '最后登录']
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
const blob = new Blob([`\ufeff${csv}`], {type: 'text/csv;charset=utf-8;'})
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `users-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
link.click()
URL.revokeObjectURL(link.href)
}
const reloadData = async () => {
await Promise.all([loadStats(), loadUsers()])
}
onMounted(() => {
reloadData()
})
</script>
<style lang="scss" scoped>
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.page-subtitle {
@apply text-sm text-slate-500 mt-1;
}
.page-actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.mini-stat {
@apply rounded-xl p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value {
@apply text-3xl font-bold;
}
&__label {
@apply text-sm opacity-90 mt-2;
}
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.filter-card {
display: grid;
grid-template-columns: 1.4fr 180px 100px 100px;
gap: 12px;
}
.table-footer {
@apply flex justify-end mt-4;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-grid span {
color: #94a3b8;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-card {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,353 @@
<template>
<div class="cart-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center">
<el-icon class="text-blue-500 mr-2">
<ShoppingCart/>
</el-icon>
购物车
</h1>
</div>
<div v-if="cartStore.loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="cartStore.items.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="购物车空空如也">
<el-button type="primary" @click="router.push('/products')">
去购物
</el-button>
</el-empty>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧购物车列表 -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm">
<!-- 全选栏 -->
<div class="border-b px-6 py-4 flex justify-between items-center">
<el-checkbox
v-model="selectAll"
:indeterminate="indeterminate"
@change="handleSelectAll"
>
全选{{ cartStore.selectedCount }}/{{ cartStore.itemCount }}
</el-checkbox>
<el-button
:disabled="cartStore.selectedCount === 0"
text
type="danger"
@click="handleBatchRemove"
>
<el-icon class="mr-1">
<Delete/>
</el-icon>
删除选中
</el-button>
</div>
<!-- 商品列表 -->
<div class="divide-y">
<div
v-for="item in cartStore.items"
:key="item.id"
class="p-6 hover:bg-gray-50 transition-colors"
>
<div class="flex gap-4">
<!-- 选择框 -->
<el-checkbox
v-model="item.selected"
@change="handleSelectItem(item.id)"
/>
<!-- 商品图片 -->
<SafeImage :alt="item.productName" :src="item.productImage"
img-class="w-24 h-24 object-cover rounded-lg"
wrapper-class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100"/>
<!-- 商品信息 -->
<div class="flex-1">
<h3 class="font-semibold mb-2">
<router-link
:to="`/product/${item.productId}`"
class="hover:text-primary-500"
>
{{ item.productName }}
</router-link>
</h3>
<div class="flex items-center justify-between">
<div>
<span class="text-red-500 font-bold text-lg">
¥{{ item.price }}
</span>
<span class="ml-2 text-sm text-gray-400">
库存: {{ item.stock }}
</span>
</div>
<!-- 数量选择 -->
<div class="flex items-center gap-2">
<el-input-number
v-model="item.quantity"
:max="item.stock"
:min="1"
size="small"
@change="handleQuantityChange(item.id, item.quantity)"
/>
<el-button
size="small"
text
type="danger"
@click="handleRemoveItem(item.id)"
>
删除
</el-button>
</div>
</div>
<!-- 小计 -->
<div class="mt-2 text-right">
<span class="text-sm text-gray-500">小计</span>
<span class="text-lg font-semibold text-red-500">
¥{{ (item.price * item.quantity).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="border-t px-6 py-4 flex justify-between items-center">
<el-button text @click="handleClearCart">
<el-icon class="mr-1">
<Delete/>
</el-icon>
清空购物车
</el-button>
<el-button text type="primary" @click="router.push('/products')">
继续购物
</el-button>
</div>
</div>
</div>
<!-- 右侧结算信息 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6 sticky top-20">
<h3 class="text-lg font-semibold mb-4">订单结算</h3>
<!-- 费用明细 -->
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span class="text-gray-600">商品总额</span>
<span>¥{{ cartStore.totalPrice.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">运费</span>
<span class="text-green-500">免运费</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">优惠</span>
<span class="text-red-500">-¥0.00</span>
</div>
</div>
<!-- 总计 -->
<div class="border-t pt-4 mb-6">
<div class="flex justify-between items-center">
<span class="text-lg">应付总额</span>
<span class="text-2xl font-bold text-red-500">
¥{{ cartStore.totalPrice.toFixed(2) }}
</span>
</div>
<p class="text-sm text-gray-500 mt-1">
已选 {{ cartStore.selectedCount }} {{ cartStore.totalQuantity }}
</p>
</div>
<!-- 结算按钮 -->
<el-button
:disabled="cartStore.selectedCount === 0"
class="w-full"
size="large"
type="danger"
@click="handleCheckout"
>
去结算{{ cartStore.selectedCount }}
</el-button>
<!-- 推荐商品 -->
<div class="mt-6 pt-6 border-t">
<h4 class="font-semibold mb-3">为你推荐</h4>
<div class="space-y-3">
<div
v-for="item in recommendProducts"
:key="item.id"
class="flex gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded"
@click="router.push(`/product/${item.id}`)"
>
<SafeImage :alt="item.name" :src="item.imageUrl"
img-class="w-16 h-16 object-cover rounded"
wrapper-class="w-16 h-16 rounded overflow-hidden bg-gray-100"/>
<div class="flex-1">
<p class="text-sm line-clamp-2">{{ item.name }}</p>
<p class="text-red-500 font-semibold">¥{{ item.price }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {useCartStore} from '@/stores/cart'
import {productApi} from '@/api/modules/product'
import {cartApi} from '@/api/modules/cart'
import type {Product} from '@/types/api'
import SafeImage from '@/components/common/SafeImage.vue'
const router = useRouter()
const cartStore = useCartStore()
const recommendProducts = ref<Product[]>([])
// 全选状态
const selectAll = computed({
get: () => cartStore.isAllSelected,
set: (value) => {
if (value !== cartStore.isAllSelected) {
cartStore.toggleSelectAll()
}
}
})
const indeterminate = computed(() => {
return cartStore.selectedCount > 0 && cartStore.selectedCount < cartStore.itemCount
})
// 处理图片错误
// 全选/取消全选
const handleSelectAll = () => {
cartStore.toggleSelectAll()
}
// 选择单个商品
const handleSelectItem = (itemId: string) => {
cartStore.toggleSelect(itemId)
}
// 修改数量
const handleQuantityChange = async (itemId: string, quantity: number) => {
await cartStore.updateQuantity(itemId, quantity)
}
// 删除单个商品
const handleRemoveItem = async (itemId: string) => {
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cartStore.removeItem(itemId)
}
// 批量删除
const handleBatchRemove = async () => {
if (cartStore.selectedCount === 0) return
await ElMessageBox.confirm(
`确定要删除选中的 ${cartStore.selectedCount} 件商品吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await cartStore.removeSelected()
}
// 清空购物车
const handleClearCart = async () => {
await ElMessageBox.confirm('确定要清空购物车吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cartStore.clearCart()
}
// 去结算
const handleCheckout = async () => {
if (cartStore.selectedCount === 0) {
ElMessage.warning('请选择要结算的商品')
return
}
try {
const selectedIds = cartStore.selectedItems.map((item) => item.id)
const res = await cartApi.checkout(selectedIds)
if (res.success) {
ElMessage.success('下单成功,请及时支付')
await cartStore.fetchCart()
router.push(`/order/${res.data.id}`)
}
} catch (error) {
console.error('购物车结算失败:', error)
}
}
// 加载推荐商品
const loadRecommendProducts = async () => {
try {
const res = await productApi.getHot(3)
if (res.success) {
recommendProducts.value = res.data
}
} catch (error) {
console.error('加载推荐商品失败:', error)
}
}
onMounted(() => {
cartStore.fetchCart()
loadRecommendProducts()
})
</script>
<style lang="scss" scoped>
.cart-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="error-404">
<div class="container mx-auto px-4 py-16 text-center">
<el-icon :size="120" class="text-gray-300 mb-8">
<Warning/>
</el-icon>
<h1 class="text-6xl font-bold text-gray-800 mb-4">404</h1>
<p class="text-2xl text-gray-600 mb-8">页面未找到</p>
<p class="text-gray-500 mb-8">抱歉您访问的页面不存在或已被移除</p>
<div class="space-x-4">
<el-button size="large" type="primary" @click="router.push('/')">
<el-icon class="mr-2">
<HomeFilled/>
</el-icon>
返回首页
</el-button>
<el-button size="large" @click="router.back()">
<el-icon class="mr-2">
<Back/>
</el-icon>
返回上一页
</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {useRouter} from 'vue-router'
const router = useRouter()
</script>
<style lang="scss" scoped>
.error-404 {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div class="flashsale-detail-page">
<div class="container mx-auto px-4 py-8">
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/flashsale' }">限时活动</el-breadcrumb-item>
<el-breadcrumb-item>{{ flashSale?.productName || '详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="!flashSale" class="text-center py-12">
<el-empty description="限时活动不存在"/>
<el-button type="primary" @click="router.push('/flashsale')">返回限时列表</el-button>
</div>
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
<div>
<div class="relative">
<SafeImage
:alt="flashSale.productName"
:src="flashSale.productImageUrl"
img-class="w-full rounded-lg object-cover"
wrapper-class="w-full rounded-lg overflow-hidden bg-gray-100"
/>
<div class="absolute top-4 left-4">
<el-tag :type="statusType" effect="dark" size="large">
<el-icon class="mr-1">
<Lightning/>
</el-icon>
{{ statusText }}
</el-tag>
</div>
</div>
</div>
<div>
<h1 class="text-3xl font-bold mb-4">{{ flashSale.productName }}</h1>
<div class="price-card rounded-lg p-6 mb-6">
<div class="flex items-end mb-2">
<span class="text-sm text-gray-500 mr-2">活动价</span>
<span class="detail-price">¥{{ flashSale.flashPrice }}</span>
<span class="ml-4 text-lg text-gray-400 line-through">¥{{ flashSale.originalPrice }}</span>
<span class="discount-pill">{{ discountPercent }}% OFF</span>
</div>
<div class="text-sm text-gray-600 mt-4">
<p>开始时间{{ formatTime(flashSale.startTime) }}</p>
<p>结束时间{{ formatTime(flashSale.endTime) }}</p>
</div>
</div>
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">库存情况</span>
<span class="text-sm text-gray-500">剩余 {{ flashSale.remainingStock }} / {{
flashSale.flashStock
}} </span>
</div>
<el-progress :color="progressColor" :percentage="stockPercent" :stroke-width="10"/>
</div>
<div v-if="flashSale.status === 'ACTIVE'" class="mb-6">
<div class="text-center p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600 mb-2">距离结束还有</p>
<CountDown :end-time="endTime" @finish="handleFinish"/>
</div>
</div>
<div class="note-card mb-6 p-4 rounded-lg">
<div class="flex items-center">
<el-icon class="mr-2">
<InfoFilled/>
</el-icon>
<span>每人限购 {{ flashSale.limitPerUser }} </span>
</div>
</div>
<div class="space-y-4">
<el-button :disabled="!canParticipate" :loading="participating" class="w-full" size="large" type="primary"
@click="handleParticipate">
<el-icon class="mr-2">
<Lightning/>
</el-icon>
{{ buttonText }}
</el-button>
<div class="flex gap-4">
<el-button class="flex-1" size="large" @click="handleViewProduct">查看商品详情</el-button>
<el-button class="flex-1" size="large" @click="handleShare">
<el-icon class="mr-1">
<Share/>
</el-icon>
分享活动
</el-button>
</div>
</div>
<div class="rules-card mt-8 p-4 rounded-lg">
<h3 class="font-semibold mb-2">抢购说明</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li> 限时商品数量有限先到先得</li>
<li> 每个用户限购{{ flashSale.limitPerUser }}</li>
<li> 下单后请在30分钟内完成支付</li>
<li> 商品一经售出非质量问题不支持退换</li>
</ul>
</div>
</div>
</div>
<div v-if="flashSale.description" class="border-t px-8 py-6">
<h3 class="text-xl font-semibold mb-4">活动说明</h3>
<div class="text-gray-600" v-html="flashSale.description"></div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import CountDown from '@/components/business/CountDown.vue'
import SafeImage from '@/components/common/SafeImage.vue'
import {flashsaleApi} from '@/api/modules/flashsale'
import {useUserStore} from '@/stores/user'
import type {FlashSale} from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const participating = ref(false)
const flashSale = ref<FlashSale | null>(null)
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const statusType = computed(() => {
if (!flashSale.value) return 'info'
switch (flashSale.value.status) {
case 'UPCOMING':
return 'warning'
case 'ACTIVE':
return 'danger'
case 'ENDED':
return 'info'
default:
return 'info'
}
})
const statusText = computed(() => {
if (!flashSale.value) return ''
switch (flashSale.value.status) {
case 'UPCOMING':
return '即将开始'
case 'ACTIVE':
return '进行中'
case 'ENDED':
return '已结束'
default:
return ''
}
})
const discountPercent = computed(() => {
if (!flashSale.value) return 0
return Math.round((1 - flashSale.value.flashPrice / flashSale.value.originalPrice) * 100)
})
const stockPercent = computed(() => {
if (!flashSale.value || flashSale.value.flashStock === 0) return 0
return Math.round((flashSale.value.remainingStock / flashSale.value.flashStock) * 100)
})
const progressColor = computed(() => {
if (stockPercent.value > 50) return '#171715'
if (stockPercent.value > 20) return '#5e5e58'
return '#9f9f99'
})
const endTime = computed(() => {
if (!flashSale.value) return 0
return new Date(flashSale.value.endTime).getTime()
})
const canParticipate = computed(() => {
if (!flashSale.value) return false
return flashSale.value.status === 'ACTIVE' && flashSale.value.remainingStock > 0
})
const buttonText = computed(() => {
if (!flashSale.value) return '立即抢购'
if (flashSale.value.status === 'UPCOMING') return '即将开始'
if (flashSale.value.status === 'ENDED') return '已结束'
if (flashSale.value.remainingStock === 0) return '已售罄'
return '立即抢购'
})
const loadDetail = async () => {
loading.value = true
try {
const res = await flashsaleApi.getDetail(Number(route.params.id))
if (res.success) flashSale.value = res.data
} catch (error) {
console.error('加载限时详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const handleParticipate = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({path: '/login', query: {redirect: route.fullPath}})
return
}
if (!flashSale.value || !canParticipate.value) return
await ElMessageBox.confirm(`确定要抢购该商品吗?\n活动价¥${flashSale.value.flashPrice}`, '抢购确认', {
confirmButtonText: '立即抢购',
cancelButtonText: '取消',
type: 'warning',
})
participating.value = true
try {
const res = await flashsaleApi.participate({flashSaleId: flashSale.value.id, quantity: 1})
if (res.success) {
ElMessage.success('抢购成功')
router.push(`/order/${res.data.orderId}`)
}
} catch (error) {
console.error('参与限时失败:', error)
} finally {
participating.value = false
}
}
const handleViewProduct = () => {
if (flashSale.value) router.push(`/product/${flashSale.value.productId}`)
}
const handleShare = async () => {
await navigator.clipboard.writeText(window.location.href)
ElMessage.success('链接已复制,快去分享吧')
}
const handleFinish = () => {
loadDetail()
}
onMounted(() => {
loadDetail()
})
</script>
<style lang="scss" scoped>
.flashsale-detail-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.price-card,
.note-card,
.rules-card {
background: #fffaf2;
border: 1px solid #d8cebf;
}
.detail-price {
font-size: 2.25rem;
font-weight: 700;
color: #171715;
}
.discount-pill {
margin-left: 8px;
padding: 4px 10px;
border-radius: 999px;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
font-size: 12px;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,286 @@
<template>
<div class="flashsale-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 flex items-center">
<el-icon class="page-icon mr-2">
<Lightning/>
</el-icon>
限时活动
</h1>
<p class="text-gray-600">限时抢购先到先得</p>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 状态筛选 -->
<el-radio-group v-model="filters.status" @change="loadFlashSales">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="UPCOMING">即将开始</el-radio-button>
<el-radio-button label="ACTIVE">进行中</el-radio-button>
<el-radio-button label="ENDED">已结束</el-radio-button>
</el-radio-group>
<!-- 排序 -->
<el-select
v-model="filters.sort"
placeholder="排序方式"
style="width: 150px"
@change="loadFlashSales"
>
<el-option label="开始时间" value="startTime"/>
<el-option label="结束时间" value="endTime"/>
<el-option label="价格从低到高" value="flashPrice"/>
<el-option label="折扣力度" value="discount"/>
</el-select>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索商品名称"
style="width: 200px"
@keyup.enter="loadFlashSales"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadFlashSales">
<Search/>
</el-icon>
</template>
</el-input>
<!-- 刷新按钮 -->
<el-button @click="handleRefresh">
<el-icon class="mr-1">
<Refresh/>
</el-icon>
刷新
</el-button>
</div>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card tone-1">
<div class="stat-value">{{ statistics.upcoming }}</div>
<div class="stat-label">即将开始</div>
<el-icon :size="30" class="stat-icon">
<Clock/>
</el-icon>
</div>
<div class="stat-card tone-2">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">正在进行</div>
<el-icon :size="30" class="stat-icon">
<Lightning/>
</el-icon>
</div>
<div class="stat-card tone-3">
<div class="stat-value">{{ statistics.participated }}</div>
<div class="stat-label">我的参与</div>
<el-icon :size="30" class="stat-icon">
<Trophy/>
</el-icon>
</div>
<div class="stat-card tone-4">
<div class="stat-value">{{ statistics.success }}</div>
<div class="stat-label">抢购成功</div>
<el-icon :size="30" class="stat-icon">
<SuccessFilled/>
</el-icon>
</div>
</div>
<!-- 限时活动列表 -->
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="flashSales.length === 0" class="text-center py-12">
<el-empty description="暂无限时活动"/>
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<FlashSaleCard
v-for="item in flashSales"
:key="item.id"
:data="item"
@participate="handleParticipate"
@refresh="loadFlashSales"
/>
</div>
<!-- 分页 -->
<div class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[12, 24, 36, 48]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadFlashSales"
@current-change="loadFlashSales"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
import {flashsaleApi} from '@/api/modules/flashsale'
import {useUserStore} from '@/stores/user'
import type {FlashSale} from '@/types/api'
const router = useRouter()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const flashSales = ref<FlashSale[]>([])
// 筛选条件
const filters = reactive({
status: '',
sort: 'startTime',
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
size: 12,
total: 0
})
// 统计信息
const statistics = reactive({
upcoming: 0,
active: 0,
participated: 0,
success: 0
})
// 加载限时活动
const loadFlashSales = async () => {
loading.value = true
try {
const res = await flashsaleApi.getList({
...filters,
page: pagination.page - 1,
size: pagination.size
})
if (res.success) {
flashSales.value = res.data.content
pagination.total = res.data.totalElements
}
} catch (error) {
console.error('加载限时活动失败:', error)
} finally {
loading.value = false
}
}
// 加载统计信息(从后端获取真实数据)
const loadStatistics = async () => {
try {
const res = await flashsaleApi.getStatistics()
if (res.success) {
statistics.upcoming = res.data.upcoming ?? 0
statistics.active = res.data.active ?? 0
statistics.participated = res.data.participated ?? 0
statistics.success = res.data.success ?? 0
}
} catch (error) {
console.error('加载统计信息失败:', error)
}
}
// 参与限时
const handleParticipate = async (flashSaleId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 先检查资格
try {
const res = await flashsaleApi.checkEligibility(flashSaleId)
if (res.success && res.data.eligible) {
// 确认对话框
await ElMessageBox.confirm(
'确定要参与这个限时活动吗?',
'提示',
{
confirmButtonText: '立即抢购',
cancelButtonText: '取消',
type: 'warning',
}
)
// 跳转到详情页参与
router.push(`/flashsale/${flashSaleId}`)
} else {
ElMessage.warning(res.data.reason || '您暂时无法参与此活动')
}
} catch (error) {
// 用户取消或错误
}
}
// 刷新
const handleRefresh = () => {
loadFlashSales()
loadStatistics()
ElMessage.success('已刷新')
}
onMounted(() => {
loadFlashSales()
loadStatistics()
})
</script>
<style lang="scss" scoped>
.flashsale-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.page-icon {
color: #44443f;
}
.stat-card {
@apply relative overflow-hidden rounded-lg p-4;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
.stat-value {
@apply text-2xl font-bold;
}
.stat-label {
@apply text-sm mt-1;
}
.stat-icon {
@apply absolute right-4 bottom-4;
opacity: 0.2;
}
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div class="page-container py-8">
<div class="container mx-auto px-4">
<!-- 面包屑 -->
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
<el-breadcrumb-item>{{ detail?.productName || '加载中...' }}</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-20">
<el-icon :size="32" class="is-loading">
<Loading/>
</el-icon>
</div>
<template v-else-if="detail">
<!-- 商品信息 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<SafeImage
:alt="detail.productName"
:src="detail.productImageUrl"
img-class="w-full h-96 object-cover"
wrapper-class="w-full h-96 rounded-2xl overflow-hidden"
/>
</div>
<div>
<el-tag :type="statusType" class="mb-3" effect="dark">{{ detail.statusDescription }}</el-tag>
<h1 class="text-2xl font-bold mb-4">{{ detail.productName }}</h1>
<div class="price-section mb-4">
<div class="flex items-end gap-3">
<span class="text-3xl font-bold" style="color: #171715">¥{{ detail.groupPrice }}</span>
<span class="text-lg text-gray-400 line-through">¥{{ detail.productPrice }}</span>
<el-tag size="small" type="danger"> ¥{{ detail.discount }}</el-tag>
</div>
</div>
<div class="info-section space-y-3 mb-6">
<div class="flex items-center text-gray-600">
<el-icon class="mr-2">
<User/>
</el-icon>
<span>{{ detail.requiredMembers }} 人成团</span>
</div>
<div class="flex items-center text-gray-600">
<el-icon class="mr-2">
<Timer/>
</el-icon>
<span>开团后 {{ detail.durationMinutes }} 分钟内有效</span>
</div>
<div class="flex items-center text-gray-600">
<el-icon class="mr-2">
<Box/>
</el-icon>
<span>剩余库存: {{ detail.remainingStock }} / {{ detail.totalStock }}</span>
</div>
<div class="flex items-center text-gray-600">
<el-icon class="mr-2">
<Warning/>
</el-icon>
<span>每人限购 {{ detail.maxPerUser }} </span>
</div>
</div>
<el-progress :color="progressColor" :percentage="stockPercent" :show-text="false" :stroke-width="8"
class="mb-6"/>
<div class="flex gap-3">
<el-button :disabled="!canJoin" :loading="joining" size="large" type="primary" @click="handleCreateGroup">
<el-icon class="mr-1">
<Connection/>
</el-icon>
一键开团
</el-button>
<el-button size="large" @click="$router.push(`/product/${detail.productId}`)">
查看商品
</el-button>
</div>
</div>
</div>
<!-- 规则说明 -->
<div class="rules-section mb-8 p-6 rounded-xl" style="background: #fffaf2; border: 1px solid #e8e0d4">
<h3 class="text-lg font-bold mb-3">拼团规则</h3>
<ul class="space-y-2 text-gray-600 text-sm">
<li>1. 用户可以发起新团或加入已有团组</li>
<li>2. 开团后 {{ detail.durationMinutes }} 分钟内需凑满 {{ detail.requiredMembers }} </li>
<li>3. 成团后按拼团价生成订单未成团自动退款</li>
<li>4. 每人限购 {{ detail.maxPerUser }} </li>
</ul>
</div>
<!-- 进行中的团组 -->
<div class="groups-section mb-8">
<h2 class="text-xl font-bold mb-4">
进行中的团组
<span class="text-sm text-gray-400 ml-2">({{ formingGroups.length }} )</span>
</h2>
<div v-if="formingGroups.length === 0" class="text-center py-10">
<el-empty description="暂无进行中的团组,快来开团吧!"/>
</div>
<div v-else class="space-y-4">
<div v-for="group in formingGroups" :key="group.id"
class="group-item p-4 rounded-xl flex items-center justify-between"
style="background: #fffaf2; border: 1px solid #e8e0d4">
<div class="flex items-center gap-4">
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
<div>
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
<div class="text-sm text-gray-500">
还差 {{ group.requiredMembers - group.currentMembers }} |
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroups"/>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex -space-x-2">
<el-avatar v-for="m in group.members.slice(0, 5)" :key="m.userId" :size="28" :src="m.avatar">
{{ m.username ? m.username[0] : '?' }}
</el-avatar>
</div>
<el-button :loading="joining" size="small" type="primary" @click="handleJoinGroup(group.id)">
参团
</el-button>
</div>
</div>
</div>
</div>
<!-- 已成团的团组 -->
<div v-if="completedGroups.length > 0" class="groups-section">
<h2 class="text-xl font-bold mb-4">
已成团
<span class="text-sm text-gray-400 ml-2">({{ completedGroups.length }} )</span>
</h2>
<div class="space-y-4">
<div v-for="group in completedGroups" :key="group.id"
class="group-item p-4 rounded-xl flex items-center justify-between"
style="background: #f8f8f6; border: 1px solid #e8e0d4; opacity: 0.85">
<div class="flex items-center gap-4">
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
<div>
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
<div class="text-sm text-green-600">
<el-icon class="mr-1">
<CircleCheckFilled/>
</el-icon>
已成团 · {{ group.currentMembers }}/{{ group.requiredMembers }}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex -space-x-2">
<el-avatar v-for="m in group.members.slice(0, 5)" :key="m.userId" :size="28" :src="m.avatar">
{{ m.username ? m.username[0] : '?' }}
</el-avatar>
</div>
<el-tag size="small" type="success">已成团</el-tag>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {Loading, CircleCheckFilled} from '@element-plus/icons-vue'
import type {GroupBuying, GroupBuyingGroup} from '@/types/api'
import {groupbuyingApi} from '@/api/modules/groupbuying'
import SafeImage from '@/components/common/SafeImage.vue'
import CountDown from '@/components/business/CountDown.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const joining = ref(false)
const detail = ref<GroupBuying | null>(null)
const groups = ref<GroupBuyingGroup[]>([])
const id = computed(() => Number(route.params.id))
const formingGroups = computed(() => groups.value.filter(g => g.status === 'FORMING'))
const completedGroups = computed(() => groups.value.filter(g => g.status === 'SUCCESS'))
const statusType = computed(() => {
switch (detail.value?.status) {
case 'UPCOMING':
return 'warning'
case 'ACTIVE':
return 'success'
case 'ENDED':
return 'info'
default:
return 'info'
}
})
const stockPercent = computed(() => {
if (!detail.value || detail.value.totalStock === 0) return 0
return Math.round(detail.value.remainingStock / detail.value.totalStock * 100)
})
const progressColor = computed(() => (stockPercent.value > 50 ? '#171715' : stockPercent.value > 20 ? '#5e5e58' : '#9f9f99'))
const canJoin = computed(() => detail.value?.status === 'ACTIVE' && (detail.value?.remainingStock ?? 0) > 0)
const loadDetail = async () => {
loading.value = true
try {
const res = await groupbuyingApi.getDetail(id.value)
detail.value = res.data
await loadGroups()
} catch (e) {
console.error('加载拼团详情失败', e)
} finally {
loading.value = false
}
}
const loadGroups = async () => {
try {
const res = await groupbuyingApi.getGroups(id.value, {page: 0, size: 50})
groups.value = res.data.content
} catch (e) {
console.error('加载团组列表失败', e)
}
}
const handleCreateGroup = async () => {
joining.value = true
try {
const res = await groupbuyingApi.joinGroup({groupBuyingId: id.value})
ElMessage.success(res.data.message || '开团成功')
router.push(`/groupbuying/group/${res.data.groupId}`)
} catch {
// 全局拦截器已处理错误提示
} finally {
joining.value = false
}
}
const handleJoinGroup = async (groupId: number) => {
joining.value = true
try {
const res = await groupbuyingApi.joinGroup({groupBuyingId: id.value, groupId})
ElMessage.success(res.data.message || '加入成功')
router.push(`/groupbuying/group/${res.data.groupId}`)
} catch {
// 全局拦截器已处理错误提示
} finally {
joining.value = false
}
}
onMounted(loadDetail)
</script>
<style lang="scss" scoped>
.price-section {
@apply p-4 rounded-xl;
background: #fffaf2;
border: 1px solid #e8e0d4;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div class="page-container py-8">
<div class="container mx-auto px-4">
<!-- 面包屑 -->
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
<el-breadcrumb-item>团组详情</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-20">
<el-icon :size="32" class="is-loading">
<Loading/>
</el-icon>
</div>
<template v-else-if="group">
<div class="max-w-2xl mx-auto">
<!-- 团组状态 -->
<div class="status-section text-center mb-8 p-8 rounded-2xl"
style="background: #fffaf2; border: 1px solid #e8e0d4">
<el-tag :type="statusType" class="mb-4" effect="dark" size="large">{{ group.statusDescription }}</el-tag>
<h2 class="text-xl font-bold mb-2">{{ group.groupBuying?.productName }}</h2>
<div class="text-2xl font-bold mb-4" style="color: #171715">¥{{ group.groupBuying?.groupPrice }}</div>
<div v-if="group.status === 'FORMING'" class="mb-4">
<p class="text-gray-500 mb-2">还差 <span class="font-bold text-lg" style="color: #171715">{{
group.requiredMembers - group.currentMembers
}}</span> 人成团</p>
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroup"/>
</div>
<div v-else-if="group.status === 'SUCCESS'" class="mb-4">
<el-icon :size="48" color="#67c23a">
<CircleCheckFilled/>
</el-icon>
<p class="text-green-600 mt-2 font-semibold">拼团成功!</p>
</div>
<div v-else class="mb-4">
<el-icon :size="48" color="#909399">
<CircleCloseFilled/>
</el-icon>
<p class="text-gray-500 mt-2">拼团未成功</p>
</div>
</div>
<!-- 成员列表 -->
<div class="members-section mb-8">
<h3 class="text-lg font-bold mb-4">团成员 ({{ group.currentMembers }}/{{ group.requiredMembers }})</h3>
<GroupMemberList
:leader-user-id="group.leaderUserId"
:members="group.members"
:required-members="group.requiredMembers"
/>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 justify-center">
<el-button v-if="group.status === 'FORMING' && !isInGroup" :loading="joining" size="large" type="primary"
@click="handleJoin">
<el-icon class="mr-1">
<Connection/>
</el-icon>
加入拼团
</el-button>
<el-button v-if="group.status === 'FORMING' && isInGroup" :loading="cancelling" size="large" type="danger"
@click="handleCancel">
退出团组
</el-button>
<el-button size="large" @click="$router.push(`/groupbuying/${group.groupBuyingId}`)">
查看活动
</el-button>
<el-button v-if="group.status === 'FORMING'" size="large" @click="handleShare">
<el-icon class="mr-1">
<Share/>
</el-icon>
邀请好友
</el-button>
</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {Loading} from '@element-plus/icons-vue'
import type {GroupBuyingGroup} from '@/types/api'
import {groupbuyingApi} from '@/api/modules/groupbuying'
import {useUserStore} from '@/stores/user'
import GroupMemberList from '@/components/business/GroupMemberList.vue'
import CountDown from '@/components/business/CountDown.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(true)
const joining = ref(false)
const cancelling = ref(false)
const group = ref<GroupBuyingGroup | null>(null)
const groupId = computed(() => Number(route.params.id))
const statusType = computed(() => {
switch (group.value?.status) {
case 'FORMING':
return 'warning'
case 'SUCCESS':
return 'success'
case 'FAILED':
return 'info'
default:
return 'info'
}
})
const isInGroup = computed(() => {
if (!group.value || !userStore.user) return false
return group.value.members.some(m => m.userId === userStore.user?.id)
})
const loadGroup = async () => {
loading.value = true
try {
const res = await groupbuyingApi.getGroupDetail(groupId.value)
group.value = res.data
} catch (e) {
console.error('加载团组详情失败', e)
} finally {
loading.value = false
}
}
const handleJoin = async () => {
if (!group.value) return
joining.value = true
try {
const res = await groupbuyingApi.joinGroup({
groupBuyingId: group.value.groupBuyingId,
groupId: group.value.id,
})
ElMessage.success(res.data.message || '加入成功')
await loadGroup()
} catch {
// 全局拦截器已处理错误提示
} finally {
joining.value = false
}
}
const handleCancel = async () => {
try {
await ElMessageBox.confirm('确定要退出该团组吗?退出后订单将自动取消。', '提示', {
confirmButtonText: '确定退出',
cancelButtonText: '取消',
type: 'warning',
})
cancelling.value = true
await groupbuyingApi.cancelMembership(groupId.value)
ElMessage.success('已退出团组')
await loadGroup()
} catch (e: any) {
if (e !== 'cancel' && !(e instanceof Error)) {
// ElMessageBox 取消操作,忽略
}
// API 错误由全局拦截器处理
} finally {
cancelling.value = false
}
}
const handleShare = () => {
const url = window.location.href
navigator.clipboard.writeText(url).then(() => {
ElMessage.success('链接已复制,快分享给好友吧!')
}).catch(() => {
ElMessage.info('请手动复制链接分享')
})
}
onMounted(loadGroup)
</script>

View File

@@ -0,0 +1,152 @@
<template>
<div class="page-container py-8">
<div class="container mx-auto px-4">
<!-- 页头 -->
<div class="flex items-center mb-6">
<el-icon :size="28" class="mr-2">
<Connection/>
</el-icon>
<h1 class="text-2xl font-bold">拼团活动</h1>
</div>
<!-- 筛选栏 -->
<div class="filter-bar mb-6 flex flex-wrap items-center gap-4">
<el-radio-group v-model="filters.status" @change="loadList">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="ACTIVE">进行中</el-radio-button>
<el-radio-button label="UPCOMING">即将开始</el-radio-button>
<el-radio-button label="ENDED">已结束</el-radio-button>
</el-radio-group>
<el-button :icon="Refresh" :loading="loading" @click="loadList">刷新</el-button>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-value">{{ stats.activeActivities }}</div>
<div class="stat-label">进行中</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.myGroups }}</div>
<div class="stat-label">我参与的</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.successGroups }}</div>
<div class="stat-label">已成团</div>
</div>
<div class="stat-card">
<div class="stat-value">¥{{ stats.totalSaved }}</div>
<div class="stat-label">已节省</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="text-center py-20">
<el-icon :size="32" class="is-loading">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<!-- 空状态 -->
<div v-else-if="list.length === 0" class="text-center py-20">
<el-empty description="暂无拼团活动"/>
</div>
<!-- 活动网格 -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<GroupBuyingCard
v-for="item in list"
:key="item.id"
:data="item"
@join="handleJoin"
@refresh="loadList"
/>
</div>
<!-- 分页 -->
<div v-if="totalElements > 0" class="flex justify-center mt-8">
<el-pagination
:current-page="filters.page + 1"
:page-size="filters.size"
:total="totalElements"
layout="prev, pager, next"
@current-change="(p: number) => { filters.page = p - 1; loadList() }"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {Refresh, Loading} from '@element-plus/icons-vue'
import type {GroupBuying, GroupBuyingStatistics} from '@/types/api'
import {groupbuyingApi} from '@/api/modules/groupbuying'
import GroupBuyingCard from '@/components/business/GroupBuyingCard.vue'
const router = useRouter()
const loading = ref(false)
const list = ref<GroupBuying[]>([])
const totalElements = ref(0)
const stats = ref<GroupBuyingStatistics>({
totalActivities: 0,
activeActivities: 0,
myGroups: 0,
successGroups: 0,
totalSaved: 0,
})
const filters = reactive({
status: '' as string,
page: 0,
size: 12,
})
const loadList = async () => {
loading.value = true
try {
const [listRes, statsRes] = await Promise.all([
groupbuyingApi.getList({
page: filters.page,
size: filters.size,
status: filters.status || undefined,
}),
groupbuyingApi.getStatistics(),
])
list.value = listRes.data.content
totalElements.value = listRes.data.totalElements
stats.value = statsRes.data
} catch (e) {
console.error('加载拼团列表失败', e)
} finally {
loading.value = false
}
}
const handleJoin = (id: number) => {
router.push(`/groupbuying/${id}`)
}
onMounted(loadList)
</script>
<style lang="scss" scoped>
.stat-card {
@apply bg-white rounded-xl p-4 text-center;
background: #fffaf2;
border: 1px solid #e8e0d4;
.stat-value {
@apply text-2xl font-bold;
color: #171715;
}
.stat-label {
@apply text-sm text-gray-500 mt-1;
}
}
</style>

View File

@@ -0,0 +1,393 @@
<template>
<div class="home-page">
<!-- 轮播图 -->
<el-carousel :interval="5000" arrow="hover" height="400px">
<el-carousel-item v-for="item in banners" :key="item.id">
<div :style="{ background: item.bgColor }" class="banner-content">
<div class="container mx-auto px-4 h-full">
<div class="flex items-center h-full">
<div class="w-1/2">
<h1 class="banner-title text-4xl font-bold mb-4">
<el-icon :size="40">
<Lightning/>
</el-icon>
{{ item.title }}
</h1>
<p class="banner-subtitle text-xl mb-6">{{ item.subtitle }}</p>
<div class="space-x-4">
<el-button size="large" type="primary" @click="router.push(item.link)">
{{ item.buttonText }}
</el-button>
<el-button size="large" @click="router.push('/products')">
浏览商品
</el-button>
</div>
</div>
<div class="w-1/2 text-center">
<el-icon :size="200" class="banner-illustration">
<component :is="item.icon"/>
</el-icon>
</div>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
<div class="container mx-auto px-4 py-8">
<!-- 商品分类 -->
<section class="mb-12">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold flex items-center">
<el-icon class="section-icon mr-2">
<Grid/>
</el-icon>
商品分类
</h2>
<el-button text @click="router.push('/products')">
全部商品
<el-icon class="ml-1">
<ArrowRight/>
</el-icon>
</el-button>
</div>
<div v-if="loadingCategories" class="text-center py-8">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div
v-for="cat in categoryList"
:key="cat.name"
class="category-card cursor-pointer"
@click="router.push(`/products?category=${encodeURIComponent(cat.name)}`)"
>
<el-icon :size="32" class="category-icon mb-2">
<component :is="cat.icon"/>
</el-icon>
<span class="text-sm font-medium">{{ cat.name }}</span>
</div>
</div>
</section>
<!-- 限时活动 -->
<section class="mb-12">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold flex items-center">
<el-icon class="section-icon mr-2">
<Lightning/>
</el-icon>
限时活动
</h2>
<el-button text @click="router.push('/flashsale')">
查看全部
<el-icon class="ml-1">
<ArrowRight/>
</el-icon>
</el-button>
</div>
<div v-if="loadingFlashSales" class="text-center py-8">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="activeFlashSales.length === 0" class="text-center py-8">
<el-empty description="暂无进行中的限时活动"/>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<FlashSaleCard
v-for="item in activeFlashSales"
:key="item.id"
:data="item"
@participate="handleParticipate"
/>
</div>
</section>
<!-- 热门商品 -->
<section class="mb-12">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold flex items-center">
<el-icon class="section-icon mr-2">
<Star/>
</el-icon>
热门商品
</h2>
<el-button text @click="router.push('/products')">
查看全部
<el-icon class="ml-1">
<ArrowRight/>
</el-icon>
</el-button>
</div>
<div v-if="loadingProducts" class="text-center py-8">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="hotProducts.length === 0" class="text-center py-8">
<el-empty description="暂无热门商品"/>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<ProductCard
v-for="item in hotProducts"
:key="item.id"
:data="item"
@add-to-cart="handleAddToCart"
/>
</div>
</section>
<!-- 系统特性 -->
<section class="mb-12">
<h2 class="text-2xl font-bold text-center mb-8">系统特性</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="feature-card">
<el-icon :size="40" class="feature-icon mb-4">
<Lightning/>
</el-icon>
<h3 class="text-lg font-semibold mb-2">限时优惠</h3>
<p class="text-gray-600">社区生鲜团购系统支持热门商品集中促销</p>
</div>
<div class="feature-card">
<el-icon :size="40" class="feature-icon mb-4">
<Lock/>
</el-icon>
<h3 class="text-lg font-semibold mb-2">防超卖</h3>
<p class="text-gray-600">分布式锁机制确保库存数据一致性</p>
</div>
<div class="feature-card">
<el-icon :size="40" class="feature-icon mb-4">
<Coin/>
</el-icon>
<h3 class="text-lg font-semibold mb-2">Redis缓存</h3>
<p class="text-gray-600">五种数据类型应用毫秒级响应</p>
</div>
<div class="feature-card">
<el-icon :size="40" class="feature-icon mb-4">
<Odometer/>
</el-icon>
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
<p class="text-gray-600">多种限流策略防止恶意刷单</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
import ProductCard from '@/components/business/ProductCard.vue'
import {flashsaleApi} from '@/api/modules/flashsale'
import {productApi} from '@/api/modules/product'
import {useCartStore} from '@/stores/cart'
import {useUserStore} from '@/stores/user'
import type {FlashSale, Product} from '@/types/api'
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
// 轮播图数据
const banners = [
{
id: 1,
title: '社区生鲜团购系统',
subtitle: '社区生鲜团购系统,新鲜直达您身边',
buttonText: '查看拼团',
link: '/groupbuying',
bgColor: '#ffffff',
icon: 'Lightning'
},
{
id: 2,
title: '防超卖机制',
subtitle: '采用分布式锁和Lua脚本确保数据一致性',
buttonText: '了解更多',
link: '/groupbuying',
bgColor: '#ffffff',
icon: 'Lock'
},
{
id: 3,
title: '高性能缓存',
subtitle: 'Redis集群架构毫秒级响应',
buttonText: '查看商品',
link: '/products',
bgColor: '#ffffff',
icon: 'Odometer'
}
]
// 分类图标映射
const categoryIconMap: Record<string, string> = {
'电子产品': 'Monitor',
'家电': 'House',
'服饰鞋包': 'Goods',
'图书音像': 'Reading',
'食品饮料': 'Coffee',
'运动户外': 'Trophy',
'美妆护肤': 'MagicStick',
'家居日用': 'Box',
'母婴玩具': 'Present',
'数码配件': 'Cellphone',
}
// 数据状态
const loadingCategories = ref(false)
const loadingFlashSales = ref(false)
const loadingProducts = ref(false)
const categoryList = ref<{ name: string; icon: string }[]>([])
const activeFlashSales = ref<FlashSale[]>([])
const hotProducts = ref<Product[]>([])
// 加载分类
const loadCategories = async () => {
loadingCategories.value = true
try {
const res = await productApi.getCategories()
if (res.success) {
categoryList.value = res.data.map((name: string) => ({
name,
icon: categoryIconMap[name] || 'Goods',
}))
}
} catch (error) {
console.error('加载分类失败:', error)
} finally {
loadingCategories.value = false
}
}
// 加载限时活动
const loadFlashSales = async () => {
loadingFlashSales.value = true
try {
const res = await flashsaleApi.getActive(4)
if (res.success) {
activeFlashSales.value = res.data
}
} catch (error) {
console.error('加载限时活动失败:', error)
} finally {
loadingFlashSales.value = false
}
}
// 加载热门商品
const loadProducts = async () => {
loadingProducts.value = true
try {
const res = await productApi.getHot(8)
if (res.success) {
hotProducts.value = res.data
}
} catch (error) {
console.error('加载热门商品失败:', error)
} finally {
loadingProducts.value = false
}
}
// 参与限时
const handleParticipate = async (flashSaleId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 跳转到限时详情页
router.push(`/flashsale/${flashSaleId}`)
}
// 添加到购物车
const handleAddToCart = async (productId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
await cartStore.addToCart(productId)
}
onMounted(() => {
loadCategories()
loadFlashSales()
loadProducts()
})
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
}
.banner-content {
height: 100%;
display: flex;
align-items: center;
position: relative;
border: 1px solid #d8cebf;
border-radius: 28px;
overflow: hidden;
background: #fffaf2;
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
}
.banner-title,
.banner-subtitle {
color: #171715;
}
.banner-illustration {
color: #171715;
opacity: 0.16;
}
.section-icon,
.feature-icon {
color: #44443f;
}
.category-card {
@apply flex flex-col items-center justify-center p-5 rounded-2xl transition-all;
border: 1px solid #d8cebf;
background: #fffaf2;
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.08);
}
}
.category-icon {
color: #44443f;
}
.feature-card {
@apply bg-white p-6 rounded-2xl text-center transition-shadow;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
:deep(.el-carousel__item) {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<div class="order-detail-page">
<div class="container mx-auto px-4 py-8">
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/orders' }">我的订单</el-breadcrumb-item>
<el-breadcrumb-item>订单详情</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="!order" class="text-center py-12">
<el-empty description="订单不存在"/>
<el-button type="primary" @click="router.push('/orders')">返回订单列表</el-button>
</div>
<div v-else>
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">订单状态</h2>
<el-tag :type="getStatusType(order.status)" size="large">{{ getStatusText(order.status) }}</el-tag>
</div>
<el-steps :active="getActiveStep()" finish-status="success">
<el-step :description="formatTime(order.createdAt)" title="提交订单"/>
<el-step :description="order.paidAt ? formatTime(order.paidAt) : ''" title="支付订单"/>
<el-step :description="order.shippedAt ? formatTime(order.shippedAt) : ''" title="商家发货"/>
<el-step :description="order.completedAt ? formatTime(order.completedAt) : ''" title="确认收货"/>
</el-steps>
<div class="mt-6 flex gap-2">
<template v-if="order.status === 'PENDING'">
<el-button type="primary" @click="handlePay">立即付款</el-button>
<el-button @click="handleCancel">取消订单</el-button>
</template>
<template v-else-if="order.status === 'SHIPPED'">
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
</template>
<template v-else-if="order.status === 'COMPLETED'">
<el-button v-if="allReviewed" @click="reviewDialogVisible = true">查看评价</el-button>
<el-button v-else type="primary" @click="reviewDialogVisible = true">评价</el-button>
<el-button type="warning" @click="returnDialogVisible = true">申请退货</el-button>
<el-button @click="handleRebuy">再次购买</el-button>
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
</template>
<template v-else-if="order.status === 'REFUNDING'">
<el-button v-if="orderReturn && orderReturn.status === 'APPROVED'" type="primary"
@click="trackingDialogVisible = true">填写物流单号
</el-button>
<el-button v-if="orderReturn && (orderReturn.status === 'PENDING' || orderReturn.status === 'APPROVED')"
@click="handleCancelReturn">取消退货
</el-button>
</template>
<template v-else-if="order.status === 'REFUNDED'">
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
</template>
<template v-else-if="order.status === 'CANCELLED'">
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
</template>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">收货信息</h3>
<div v-if="order.address" class="text-sm space-y-2">
<div><span class="text-gray-500">收货人</span><span>{{ order.address.name }} {{
order.address.phone
}}</span></div>
<div><span class="text-gray-500">收货地址</span><span>{{ order.address.province }} {{ order.address.city }} {{
order.address.district
}} {{ order.address.address }}</span></div>
</div>
<div v-else class="text-gray-500">暂无收货信息</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">商品信息</h3>
<div class="space-y-4">
<div v-for="item in order.items" :key="item.id" class="flex gap-4 pb-4 border-b last:border-0">
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-24 h-24 object-cover rounded"
wrapper-class="w-24 h-24 rounded"/>
<div class="flex-1">
<h4 class="font-semibold mb-2">{{ item.productName }}</h4>
<div class="text-sm text-gray-500">单价¥{{ item.price }} × {{ item.quantity }}</div>
</div>
<div class="text-right">
<div class="font-semibold text-lg">¥{{ item.subtotal }}</div>
</div>
</div>
</div>
<div class="border-t pt-4 mt-4 space-y-2">
<div class="flex justify-between text-sm"><span
class="text-gray-500">商品总额</span><span>¥{{ order.totalAmount }}</span></div>
<div class="flex justify-between text-sm"><span class="text-gray-500">运费</span><span>¥0.00</span></div>
<div class="flex justify-between text-sm"><span class="text-gray-500">优惠</span><span class="text-red-500">-¥0.00</span>
</div>
<div class="flex justify-between text-lg font-semibold pt-2 border-t"><span>实付金额</span><span
class="text-red-500">¥{{ order.paymentAmount }}</span></div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-4">订单信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-500">订单编号</span><span>{{ order.orderNo }}</span>
<el-button size="small" text type="primary" @click="copyOrderNo">复制</el-button>
</div>
<div><span class="text-gray-500">创建时间</span><span>{{ formatTime(order.createdAt) }}</span></div>
<div v-if="order.paidAt"><span class="text-gray-500">付款时间</span><span>{{
formatTime(order.paidAt)
}}</span></div>
<div v-if="order.paymentMethod"><span
class="text-gray-500">支付方式</span><span>{{ getPaymentMethodText(order.paymentMethod) }}</span></div>
<div v-if="order.shippedAt"><span class="text-gray-500">发货时间</span><span>{{
formatTime(order.shippedAt)
}}</span></div>
<div v-if="order.completedAt"><span
class="text-gray-500">完成时间</span><span>{{ formatTime(order.completedAt) }}</span></div>
<div v-if="order.remark" class="md:col-span-2"><span
class="text-gray-500">订单备注</span><span>{{ order.remark }}</span></div>
</div>
</div>
</div>
<!-- 退货信息 -->
<div v-if="orderReturn && (order.status === 'REFUNDING' || order.status === 'REFUNDED')"
class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">退货信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-500">退货单号</span><span>{{ orderReturn.returnNo }}</span></div>
<div><span class="text-gray-500">退货状态</span>
<el-tag :type="getReturnStatusType(orderReturn.status)" size="small">{{ orderReturn.statusText }}</el-tag>
</div>
<div><span class="text-gray-500">退款金额</span><span
class="text-red-500 font-semibold">&yen;{{ orderReturn.refundAmount }}</span></div>
<div><span class="text-gray-500">退货原因</span><span>{{ orderReturn.reason }}</span></div>
<div v-if="orderReturn.description" class="md:col-span-2"><span
class="text-gray-500">详细描述</span><span>{{ orderReturn.description }}</span></div>
<div v-if="orderReturn.rejectReason" class="md:col-span-2"><span class="text-gray-500">拒绝原因</span><span
class="text-red-500">{{ orderReturn.rejectReason }}</span></div>
<div v-if="orderReturn.returnTracking"><span
class="text-gray-500">物流单号</span><span>{{ orderReturn.returnTracking }}</span></div>
<div v-if="orderReturn.adminRemark" class="md:col-span-2"><span class="text-gray-500">管理员备注</span><span>{{
orderReturn.adminRemark
}}</span></div>
<div><span class="text-gray-500">申请时间</span><span>{{ formatTime(orderReturn.createdAt) }}</span></div>
<div v-if="orderReturn.completedAt"><span
class="text-gray-500">完成时间</span><span>{{ formatTime(orderReturn.completedAt) }}</span></div>
</div>
</div>
<ReviewDialog
v-if="order"
v-model:visible="reviewDialogVisible"
:order-id="order.id"
:order-items="order.items"
@success="checkAllReviewed"
/>
<ReturnDialog
v-if="order"
v-model:visible="returnDialogVisible"
:order-id="order.id"
:refund-amount="order.paymentAmount"
@success="onReturnSuccess"
/>
<ReturnTrackingDialog
v-if="orderReturn"
v-model:visible="trackingDialogVisible"
:return-id="orderReturn.id"
@success="onReturnSuccess"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {orderApi} from '@/api/modules/order'
import {reviewApi} from '@/api/modules/review'
import {useCartStore} from '@/stores/cart'
import type {Order} from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import ReviewDialog from '@/components/business/ReviewDialog.vue'
import ReturnDialog from '@/components/business/ReturnDialog.vue'
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
import {returnApi} from '@/api/modules/return'
import type {OrderReturn} from '@/types/api'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const loading = ref(false)
const order = ref<Order | null>(null)
const reviewDialogVisible = ref(false)
const allReviewed = ref(false)
const returnDialogVisible = ref(false)
const trackingDialogVisible = ref(false)
const orderReturn = ref<OrderReturn | null>(null)
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getStatusType = (status: string) => ({
PENDING: 'warning',
PAID: 'primary',
SHIPPED: 'primary',
COMPLETED: 'success',
CANCELLED: 'info',
REFUNDING: 'warning',
REFUNDED: 'danger'
}[status] || 'info')
const getStatusText = (status: string) => ({
PENDING: '待付款',
PAID: '待发货',
SHIPPED: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退货中',
REFUNDED: '已退货'
}[status] || status)
const getPaymentMethodText = (method: string) => ({
ONLINE: '在线支付',
ALIPAY: '支付宝',
WECHAT: '微信支付',
CASH: '货到付款',
default: '默认支付'
}[method] || method)
const getActiveStep = () => {
if (!order.value) return 0
switch (order.value.status) {
case 'PENDING':
return 1
case 'PAID':
return 2
case 'SHIPPED':
return 3
case 'COMPLETED':
return 4
default:
return 0
}
}
const loadOrderDetail = async () => {
loading.value = true
try {
const res = await orderApi.getDetail(Number(route.params.id))
if (res.success) order.value = res.data
} catch (error) {
console.error('加载订单详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const copyOrderNo = () => {
if (!order.value) return
navigator.clipboard.writeText(order.value.orderNo)
ElMessage.success('订单号已复制')
}
const handlePay = async () => {
if (!order.value) return
await ElMessageBox.confirm(`订单金额:¥${order.value.paymentAmount},确认付款?`, '付款确认', {
confirmButtonText: '确认付款',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.pay(order.value.id, 'ONLINE');
ElMessage.success('付款成功');
loadOrderDetail()
} catch (error) {
console.error('付款失败:', error)
}
}
const handleCancel = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.cancel(order.value.id);
ElMessage.success('订单已取消');
loadOrderDetail()
} catch (error) {
console.error('取消订单失败:', error)
}
}
const handleConfirm = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.confirm(order.value.id);
ElMessage.success('已确认收货');
loadOrderDetail()
} catch (error) {
console.error('确认收货失败:', error)
}
}
const checkAllReviewed = async () => {
if (!order.value || order.value.status !== 'COMPLETED') return
try {
const checks = await Promise.all(
order.value.items.map(item => reviewApi.checkReview(order.value!.id, item.productId).catch(() => null))
)
allReviewed.value = checks.every(res => res?.success && res.data.reviewed)
} catch {
allReviewed.value = false
}
}
const getReturnStatusType = (status: string) => ({
PENDING: 'warning',
APPROVED: 'primary',
RETURNING: 'primary',
COMPLETED: 'success',
REJECTED: 'danger',
CANCELLED: 'info'
}[status] || 'info')
const loadReturnInfo = async () => {
if (!order.value) return
if (order.value.status !== 'REFUNDING' && order.value.status !== 'REFUNDED') return
try {
const res = await returnApi.getByOrderId(order.value.id)
if (res.success && res.data) {
orderReturn.value = res.data
}
} catch (error) {
console.error('加载退货信息失败:', error)
}
}
const onReturnSuccess = () => {
loadOrderDetail()
loadReturnInfo()
}
const handleCancelReturn = async () => {
if (!orderReturn.value) return
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
try {
await returnApi.cancel(orderReturn.value.id)
ElMessage.success('退货申请已取消')
loadOrderDetail()
loadReturnInfo()
} catch (error) {
console.error('取消退货失败:', error)
}
}
const handleRebuy = async () => {
if (!order.value) return
const firstItem = order.value.items[0]
if (!firstItem) return
await cartStore.addToCart(firstItem.productId, firstItem.quantity)
router.push('/cart')
}
const handleDelete = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.delete(order.value.id);
ElMessage.success('订单已删除');
router.push('/orders')
} catch (error) {
console.error('删除订单失败:', error)
}
}
onMounted(async () => {
await loadOrderDetail()
await Promise.all([checkAllReviewed(), loadReturnInfo()])
})
</script>
<style lang="scss" scoped>
.order-detail-page {
min-height: calc(100vh - 60px);
background: transparent;
}
</style>

View File

@@ -0,0 +1,379 @@
<template>
<div class="orders-page">
<div class="container mx-auto px-4 py-8">
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center">
<el-icon class="text-blue-500 mr-2">
<List/>
</el-icon>
我的订单
</h1>
</div>
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
<div v-for="stat in orderStats" :key="stat.key"
class="bg-white rounded-lg p-4 text-center cursor-pointer hover:shadow-md transition-shadow"
@click="handleStatusFilter(stat.key)">
<el-icon :class="stat.color" :size="24" class="mb-2">
<component :is="stat.icon"/>
</el-icon>
<div class="text-2xl font-bold">{{ stat.count }}</div>
<div class="text-sm text-gray-500">{{ stat.label }}</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<el-radio-group v-model="filters.status" @change="loadOrders">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="PENDING">待付款</el-radio-button>
<el-radio-button label="PAID">待发货</el-radio-button>
<el-radio-button label="SHIPPED">待收货</el-radio-button>
<el-radio-button label="COMPLETED">已完成</el-radio-button>
<el-radio-button label="CANCELLED">已取消</el-radio-button>
<el-radio-button label="REFUNDING">退货中</el-radio-button>
<el-radio-button label="REFUNDED">已退货</el-radio-button>
</el-radio-group>
<el-input v-model="filters.keyword" clearable placeholder="搜索订单号或商品名称" style="width: 250px"
@keyup.enter="loadOrders">
<template #suffix>
<el-icon class="cursor-pointer" @click="loadOrders">
<Search/>
</el-icon>
</template>
</el-input>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="orders.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无订单">
<el-button type="primary" @click="router.push('/products')">去购物</el-button>
</el-empty>
</div>
<div v-else class="space-y-4">
<div v-for="order in orders" :key="order.id" class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="bg-gray-50 px-6 py-3 flex justify-between items-center">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>订单号{{ order.orderNo }}</span>
<span>{{ formatTime(order.createdAt) }}</span>
</div>
<div class="flex items-center gap-2">
<el-tag v-if="order.orderType === 'FLASH_SALE'" size="small" type="danger">限时</el-tag>
<el-tag v-else-if="order.orderType === 'GROUP_BUYING'" size="small" type="success">拼团</el-tag>
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
</div>
</div>
<div class="p-6">
<div v-for="item in order.items" :key="item.id" class="flex gap-4 mb-4 last:mb-0">
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-20 h-20 object-cover rounded"
wrapper-class="w-20 h-20 rounded"/>
<div class="flex-1">
<h4 class="font-semibold">{{ item.productName }}</h4>
<div class="text-sm text-gray-500 mt-1">¥{{ item.price }} × {{ item.quantity }}</div>
</div>
<div class="text-right">
<div class="font-semibold">¥{{ item.subtotal }}</div>
</div>
</div>
</div>
<div class="border-t px-6 py-4 flex justify-between items-center">
<div>
<span class="text-sm text-gray-500">实付金额</span>
<span class="text-xl font-bold text-red-500">¥{{ order.paymentAmount || order.totalAmount }}</span>
</div>
<div class="space-x-2">
<el-button text type="primary" @click="handleViewDetail(order.id)">查看详情</el-button>
<template v-if="order.status === 'PENDING'">
<el-button size="small" type="primary" @click="handlePay(order)">立即付款</el-button>
<el-button size="small" @click="handleCancel(order)">取消订单</el-button>
</template>
<template v-else-if="order.status === 'SHIPPED'">
<el-button size="small" type="primary" @click="handleConfirm(order)">确认收货</el-button>
</template>
<template v-else-if="order.status === 'COMPLETED'">
<el-button v-if="orderReviewStatus[order.id]" size="small" @click="openReviewDialog(order)">查看评价
</el-button>
<el-button v-else size="small" type="primary" @click="openReviewDialog(order)">评价</el-button>
<el-button size="small" type="warning" @click="openReturnDialog(order)">申请退货</el-button>
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
<el-button size="small" text type="danger" @click="handleDelete(order)">删除订单</el-button>
</template>
<template v-else-if="order.status === 'REFUNDING'">
<el-button size="small" type="primary" @click="handleViewDetail(order.id)">查看退货进度</el-button>
</template>
<template v-else-if="order.status === 'REFUNDED'">
<el-button size="small" text type="danger" @click="handleDelete(order)">删除订单</el-button>
</template>
<template v-else-if="order.status === 'CANCELLED'">
<el-button size="small" text type="danger" @click="handleDelete(order)">删除订单</el-button>
</template>
</div>
</div>
</div>
</div>
<div v-if="orders.length > 0" class="mt-8 flex justify-center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]" :total="pagination.total"
layout="total, sizes, prev, pager, next, jumper" @size-change="loadOrders"
@current-change="loadOrders"/>
</div>
<ReviewDialog
v-if="currentReviewOrder"
v-model:visible="reviewDialogVisible"
:order-id="currentReviewOrder.id"
:order-items="currentReviewOrder.items"
@success="onReviewSuccess"
/>
<ReturnDialog
v-if="currentReturnOrder"
v-model:visible="returnDialogVisible"
:order-id="currentReturnOrder.id"
:refund-amount="currentReturnOrder.paymentAmount || currentReturnOrder.totalAmount"
@success="onReturnSuccess"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {orderApi} from '@/api/modules/order'
import {reviewApi} from '@/api/modules/review'
import {useCartStore} from '@/stores/cart'
import type {Order} from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import ReviewDialog from '@/components/business/ReviewDialog.vue'
import ReturnDialog from '@/components/business/ReturnDialog.vue'
const router = useRouter()
const cartStore = useCartStore()
const loading = ref(false)
const orders = ref<Order[]>([])
const filters = reactive({status: '', keyword: ''})
const pagination = reactive({page: 1, size: 10, total: 0})
const reviewDialogVisible = ref(false)
const currentReviewOrder = ref<Order | null>(null)
const orderReviewStatus = ref<Record<number, boolean>>({})
const returnDialogVisible = ref(false)
const currentReturnOrder = ref<Order | null>(null)
const orderStats = ref([
{key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500'},
{key: 'PENDING', label: '待付款', count: 0, icon: 'Clock', color: 'text-orange-500'},
{key: 'PAID', label: '待发货', count: 0, icon: 'Box', color: 'text-blue-500'},
{key: 'SHIPPED', label: '待收货', count: 0, icon: 'Van', color: 'text-purple-500'},
{key: 'COMPLETED', label: '已完成', count: 0, icon: 'CircleCheck', color: 'text-green-500'},
{key: 'CANCELLED', label: '已取消', count: 0, icon: 'CircleClose', color: 'text-gray-400'},
])
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getStatusType = (status: string) => ({
PENDING: 'warning',
PAID: 'primary',
SHIPPED: 'primary',
COMPLETED: 'success',
CANCELLED: 'info',
REFUNDING: 'warning',
REFUNDED: 'danger'
}[status] || 'info')
const getStatusText = (status: string) => ({
PENDING: '待付款',
PAID: '待发货',
SHIPPED: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退货中',
REFUNDED: '已退货'
}[status] || status)
const loadOrders = async () => {
loading.value = true
try {
const res = await orderApi.getList({
page: pagination.page - 1,
size: pagination.size,
status: filters.status || undefined
})
if (res.success) {
const keyword = filters.keyword.trim().toLowerCase()
const list = res.data.content
orders.value = keyword
? list.filter((order) => order.orderNo.toLowerCase().includes(keyword) || order.items.some((item) => item.productName.toLowerCase().includes(keyword)))
: list
pagination.total = res.data.totalElements
checkOrdersReviewStatus(orders.value)
}
} finally {
loading.value = false
}
}
const loadStatistics = async () => {
try {
const res = await orderApi.getStatistics()
if (res.success) {
orderStats.value[0].count = res.data.total
orderStats.value[1].count = res.data.pending
orderStats.value[2].count = res.data.paid
orderStats.value[3].count = res.data.shipped
orderStats.value[4].count = res.data.completed
orderStats.value[5].count = res.data.cancelled
}
} catch (error) {
console.error('加载统计失败:', error)
}
}
const handleStatusFilter = (status: string) => {
filters.status = status;
pagination.page = 1;
loadOrders()
}
const handleViewDetail = (orderId: number) => router.push(`/order/${orderId}`)
const handlePay = async (order: Order) => {
await ElMessageBox.confirm(`订单金额:¥${order.paymentAmount || order.totalAmount},确认付款?`, '付款确认', {
confirmButtonText: '确认付款',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.pay(order.id, 'ONLINE');
ElMessage.success('付款成功');
loadOrders();
loadStatistics()
} catch (error) {
console.error('付款失败:', error)
}
}
const handleCancel = async (order: Order) => {
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.cancel(order.id);
ElMessage.success('订单已取消');
loadOrders();
loadStatistics()
} catch (error) {
console.error('取消订单失败:', error)
}
}
const handleConfirm = async (order: Order) => {
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.confirm(order.id);
ElMessage.success('已确认收货');
loadOrders();
loadStatistics()
} catch (error) {
console.error('确认收货失败:', error)
}
}
const checkOrdersReviewStatus = async (orderList: Order[]) => {
const completed = orderList.filter(o => o.status === 'COMPLETED')
await Promise.all(
completed.map(async (order) => {
try {
const checks = await Promise.all(
order.items.map(item => reviewApi.checkReview(order.id, item.productId).catch(() => null))
)
orderReviewStatus.value[order.id] = checks.every(res => res?.success && res.data.reviewed)
} catch {
orderReviewStatus.value[order.id] = false
}
})
)
}
const openReviewDialog = (order: Order) => {
currentReviewOrder.value = order
reviewDialogVisible.value = true
}
const onReviewSuccess = () => {
if (currentReviewOrder.value) {
checkOrdersReviewStatus([currentReviewOrder.value])
}
}
const openReturnDialog = (order: Order) => {
currentReturnOrder.value = order
returnDialogVisible.value = true
}
const onReturnSuccess = () => {
loadOrders()
loadStatistics()
}
const handleRebuy = async (order: Order) => {
const firstItem = order.items[0]
if (!firstItem) return
await cartStore.addToCart(firstItem.productId, firstItem.quantity)
router.push('/cart')
}
const handleDelete = async (order: Order) => {
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
try {
await orderApi.delete(order.id);
ElMessage.success('订单已删除');
loadOrders();
loadStatistics()
} catch (error) {
console.error('删除订单失败:', error)
}
}
onMounted(() => {
loadOrders();
loadStatistics()
})
</script>
<style lang="scss" scoped>
.orders-page {
min-height: calc(100vh - 60px);
background: transparent;
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div class="product-detail-page">
<div class="container mx-auto px-4 py-8">
<!-- 面包屑 -->
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
<el-breadcrumb-item>{{ product?.name || '商品详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="!product" class="text-center py-12">
<el-empty description="商品不存在"/>
<el-button type="primary" @click="router.push('/products')">
返回商品列表
</el-button>
</div>
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
<!-- 左侧商品图片 -->
<div>
<div class="relative">
<SafeImage :alt="product?.name || '商品图片'" :src="currentImage"
img-class="w-full h-[420px] object-cover"
wrapper-class="w-full h-[420px] rounded-2xl overflow-hidden bg-gray-100"/>
<!-- 商品状态 -->
<div v-if="product.stock === 0" class="absolute top-4 right-4">
<el-tag size="large" type="info">已售罄</el-tag>
</div>
</div>
<!-- 图片列表 -->
<div v-if="product.images && product.images.length > 1" class="mt-4 flex gap-2 overflow-x-auto">
<img
v-for="(img, index) in product.images"
:key="index"
:alt="`${product.name}-${index}`"
:class="currentImage === img ? 'border-primary-500' : 'border-transparent'"
:src="img"
class="w-20 h-20 object-cover rounded cursor-pointer border-2"
@click="currentImage = img"
@error="handleImageError"
>
</div>
</div>
<!-- 右侧商品信息 -->
<div>
<h1 class="text-3xl font-bold mb-4">{{ product.name }}</h1>
<!-- 商品描述 -->
<p class="text-gray-600 mb-6">{{ product.description || '暂无描述' }}</p>
<!-- 价格 -->
<div class="bg-gray-50 rounded-lg p-6 mb-6">
<div class="flex items-end mb-4">
<span class="text-sm text-gray-500 mr-2">价格</span>
<span class="text-4xl font-bold text-red-500">¥{{ product.price }}</span>
</div>
<!-- 销售信息 -->
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-500">销量</span>
<p class="font-semibold">{{ product.sales || 0 }}</p>
</div>
<div>
<span class="text-gray-500">库存</span>
<p class="font-semibold">{{ product.stock }}</p>
</div>
<div>
<span class="text-gray-500">浏览</span>
<p class="font-semibold">{{ product.views || 0 }}</p>
</div>
</div>
</div>
<!-- 购买数量 -->
<div class="mb-6">
<label class="block text-sm text-gray-600 mb-2">购买数量</label>
<el-input-number
v-model="quantity"
:disabled="product.stock === 0"
:max="product.stock"
:min="1"
/>
<span class="ml-3 text-sm text-gray-500">
库存 {{ product.stock }}
</span>
</div>
<!-- 操作按钮 -->
<div class="flex gap-4 mb-6">
<el-button
:disabled="product.stock === 0"
size="large"
type="primary"
@click="handleAddToCart"
>
<el-icon class="mr-2">
<ShoppingCart/>
</el-icon>
加入购物车
</el-button>
<el-button
:disabled="product.stock === 0"
size="large"
type="danger"
@click="handleBuyNow"
>
立即购买
</el-button>
<el-button size="large" @click="handleFavorite">
<el-icon class="mr-1">
<StarFilled v-if="isFavorited" class="text-yellow-500"/>
<Star v-else/>
</el-icon>
{{ isFavorited ? '已收藏' : '收藏' }}
</el-button>
</div>
<!-- 商品信息 -->
<div class="border-t pt-6">
<h3 class="font-semibold mb-4">商品信息</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">商品编号</dt>
<dd class="font-medium">{{ product.id }}</dd>
</div>
<div>
<dt class="text-gray-500">商品分类</dt>
<dd class="font-medium">{{ product.category }}</dd>
</div>
<div>
<dt class="text-gray-500">用户评分</dt>
<dd class="font-medium">{{ reviewSummary.averageRating.toFixed(1) }} / 5{{
reviewSummary.totalReviews
}}
</dd>
</div>
<div>
<dt class="text-gray-500">上架时间</dt>
<dd class="font-medium">{{ formatTime(product.createdAt) }}</dd>
</div>
<div>
<dt class="text-gray-500">更新时间</dt>
<dd class="font-medium">{{ formatTime(product.updatedAt) }}</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- 商品详情 -->
<div class="border-t">
<el-tabs v-model="activeTab" class="px-8">
<el-tab-pane label="商品详情" name="detail">
<div class="py-6">
<div class="prose max-w-none" v-html="product.description || '暂无详细描述'"></div>
</div>
</el-tab-pane>
<el-tab-pane label="用户评价" name="reviews">
<div class="py-6">
<div class="mb-6 bg-gray-50 rounded-lg p-4">
<div class="flex items-center gap-8 mb-4">
<div class="text-center">
<div class="text-3xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
<el-rate :model-value="reviewSummary.averageRating" class="mt-1" disabled/>
<div class="text-sm text-gray-500 mt-1">累计 {{ reviewSummary.totalReviews }} 条评价</div>
</div>
<div class="flex-1 space-y-1">
<div v-for="star in [5, 4, 3, 2, 1]" :key="star" class="flex items-center gap-2">
<span class="text-sm text-gray-500 w-8">{{ star }}</span>
<el-progress
:percentage="getRatingPercentage(star)"
:show-text="false"
:stroke-width="12"
class="flex-1"
/>
<span class="text-sm text-gray-400 w-10 text-right">{{ getRatingCount(star) }}</span>
</div>
</div>
</div>
</div>
<div v-if="reviewSummary.reviews.length > 0" class="space-y-4">
<div v-for="review in displayedReviews" :key="review.id" class="border rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="font-semibold">{{ review.username }}</div>
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
</div>
<el-rate :model-value="review.rating" disabled/>
<p class="text-gray-600 mt-3 leading-6">{{ review.content }}</p>
<div v-if="review.adminReply"
class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-600">
<div class="font-medium text-gray-800 mb-1">商家回复</div>
<div>{{ review.adminReply }}</div>
</div>
</div>
<div v-if="reviewSummary.reviews.length > reviewPageSize" class="flex justify-center mt-6">
<el-pagination
v-model:current-page="reviewPage"
:page-size="reviewPageSize"
:total="reviewSummary.reviews.length"
layout="prev, pager, next"
/>
</div>
</div>
<el-empty v-else description="暂无评价"/>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="py-6">
<p class="text-gray-500">暂无规格参数</p>
</div>
</el-tab-pane>
<el-tab-pane label="售后保障" name="service">
<div class="py-6">
<ul class="space-y-2 text-gray-600">
<li> 7天无理由退换</li>
<li> 正品保证</li>
<li> 极速发货</li>
<li> 售后无忧</li>
</ul>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {productApi} from '@/api/modules/product'
import {reviewApi} from '@/api/modules/review'
import type {ReviewItem} from '@/api/modules/review'
import {favoriteApi} from '@/api/modules/favorite'
import {useCartStore} from '@/stores/cart'
import {useUserStore} from '@/stores/user'
import type {Product} from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import {DEFAULT_PRODUCT_IMAGE, resolveImageUrl} from '@/utils/image'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
const loading = ref(false)
const product = ref<Product | null>(null)
const currentImage = ref('')
const quantity = ref(1)
const activeTab = ref('detail')
const isFavorited = ref(false)
const reviewSummary = ref({averageRating: 0, totalReviews: 0, reviews: [] as ReviewItem[]})
const reviewPage = ref(1)
const reviewPageSize = 10
const defaultProductImage = DEFAULT_PRODUCT_IMAGE
const displayedReviews = computed(() => {
const start = (reviewPage.value - 1) * reviewPageSize
return reviewSummary.value.reviews.slice(start, start + reviewPageSize)
})
const getRatingCount = (star: number) => {
return reviewSummary.value.reviews.filter(r => r.rating === star).length
}
const getRatingPercentage = (star: number) => {
const total = reviewSummary.value.reviews.length
if (total === 0) return 0
return Math.round((getRatingCount(star) / total) * 100)
}
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD')
}
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
if (target) {
target.src = defaultProductImage
}
}
// 加载商品详情
const loadProductDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await productApi.getDetail(id)
if (res.success) {
product.value = res.data
currentImage.value = resolveImageUrl(res.data.imageUrl || res.data.images?.[0] || '')
await loadReviews(res.data.id)
if (userStore.isLoggedIn) {
const favoriteRes = await favoriteApi.check(res.data.id)
if (favoriteRes.success) {
isFavorited.value = favoriteRes.data.favorited
}
}
}
} catch (error) {
console.error('加载商品详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const loadReviews = async (productId: number) => {
try {
const res = await reviewApi.getProductReviews(productId)
if (res.success) {
reviewSummary.value = res.data
}
} catch (error) {
console.error('加载评价失败:', error)
}
}
// 加入购物车
const handleAddToCart = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: {redirect: route.fullPath}
})
return
}
if (!product.value) return
const success = await cartStore.addToCart(product.value.id, quantity.value)
if (success) {
quantity.value = 1
}
}
// 立即购买
const handleBuyNow = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: {redirect: route.fullPath}
})
return
}
if (!product.value) return
// 先加入购物车
const success = await cartStore.addToCart(product.value.id, quantity.value)
if (success) {
// 跳转到购物车
router.push('/cart')
}
}
// 收藏/取消收藏
const handleFavorite = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: {redirect: route.fullPath}
})
return
}
if (!product.value) return
const res = await favoriteApi.toggle(product.value.id)
if (res.success) {
isFavorited.value = res.data.favorited
ElMessage.success(isFavorited.value ? '已收藏' : '已取消收藏')
}
}
onMounted(() => {
loadProductDetail()
if (route.query.tab === 'reviews') {
activeTab.value = 'reviews'
}
})
</script>
<style lang="scss" scoped>
.product-detail-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.prose {
max-width: none;
:deep(img) {
max-width: 100%;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,293 @@
<template>
<div class="products-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 flex items-center">
<el-icon class="page-icon mr-2">
<ShoppingBag/>
</el-icon>
商品列表
</h1>
<p class="text-gray-600">精选好物品质保证</p>
</div>
<!-- 分类标签栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<div class="flex flex-wrap gap-2">
<el-tag
:effect="!filters.category ? 'dark' : 'plain'"
class="cursor-pointer category-tag"
@click="selectCategory('')"
>
全部
</el-tag>
<el-tag
v-for="cat in categories"
:key="cat"
:effect="filters.category === cat ? 'dark' : 'plain'"
class="cursor-pointer category-tag"
@click="selectCategory(cat)"
>
{{ cat }}
</el-tag>
</div>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 分类筛选 -->
<el-select
v-model="filters.category"
clearable
placeholder="选择分类"
style="width: 150px"
@change="loadProducts"
>
<el-option
v-for="cat in categories"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
<!-- 价格区间 -->
<div class="flex items-center gap-2">
<el-input-number
v-model="filters.minPrice"
:min="0"
placeholder="最低价"
style="width: 120px"
@change="loadProducts"
/>
<span>-</span>
<el-input-number
v-model="filters.maxPrice"
:min="0"
placeholder="最高价"
style="width: 120px"
@change="loadProducts"
/>
</div>
<!-- 排序 -->
<el-radio-group v-model="filters.sort" @change="loadProducts">
<el-radio-button label="default">默认</el-radio-button>
<el-radio-button label="price-asc">价格升序</el-radio-button>
<el-radio-button label="price-desc">价格降序</el-radio-button>
<el-radio-button label="sales">销量</el-radio-button>
</el-radio-group>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索商品"
style="width: 200px"
@keyup.enter="loadProducts"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadProducts">
<Search/>
</el-icon>
</template>
</el-input>
</div>
</div>
<!-- 商品列表 -->
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="products.length === 0" class="text-center py-12">
<el-empty description="暂无商品"/>
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="item in products"
:key="item.id"
:data="item"
@add-to-cart="handleAddToCart"
/>
</div>
<!-- 分页 -->
<div class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[12, 24, 36, 48]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadProducts"
@current-change="loadProducts"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, onMounted, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import ProductCard from '@/components/business/ProductCard.vue'
import {productApi} from '@/api/modules/product'
import {useCartStore} from '@/stores/cart'
import {useUserStore} from '@/stores/user'
import type {Product} from '@/types/api'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const products = ref<Product[]>([])
const categories = ref<string[]>([])
// 筛选条件
const filters = reactive({
keyword: '',
category: '',
minPrice: undefined as number | undefined,
maxPrice: undefined as number | undefined,
sort: 'default'
})
// 分页
const pagination = reactive({
page: 1,
size: 12,
total: 0
})
// 加载商品列表
const loadProducts = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page - 1,
size: pagination.size
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.category) params.category = filters.category
if (filters.minPrice !== undefined) params.minPrice = filters.minPrice
if (filters.maxPrice !== undefined) params.maxPrice = filters.maxPrice
// 处理排序
if (filters.sort === 'price-asc') {
params.sort = 'price'
params.order = 'asc'
} else if (filters.sort === 'price-desc') {
params.sort = 'price'
params.order = 'desc'
} else if (filters.sort === 'sales') {
params.sort = 'createdAt'
params.order = 'desc'
}
const res = await productApi.getList(params)
if (res.success) {
products.value = res.data.content
pagination.total = res.data.totalElements
}
} catch (error) {
console.error('加载商品列表失败:', error)
} finally {
loading.value = false
}
}
// 加载分类
const loadCategories = async () => {
try {
const res = await productApi.getCategories()
if (res.success) {
categories.value = res.data
}
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 选择分类
const selectCategory = (cat: string) => {
filters.category = cat
pagination.page = 1
loadProducts()
// 同步 URL
router.replace({query: {...route.query, category: cat || undefined}})
}
// 添加到购物车
const handleAddToCart = async (productId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
await cartStore.addToCart(productId)
}
onMounted(() => {
// 从路由参数获取搜索关键词
if (route.query.keyword) {
filters.keyword = route.query.keyword as string
}
// 从路由参数获取分类
if (route.query.category) {
filters.category = route.query.category as string
}
loadCategories()
loadProducts()
})
// 监听路由参数变化(同一页面内跳转时触发)
watch(() => route.query, (newQuery) => {
const newCategory = (newQuery.category as string) || ''
const newKeyword = (newQuery.keyword as string) || ''
if (newCategory !== filters.category || newKeyword !== filters.keyword) {
filters.category = newCategory
filters.keyword = newKeyword
pagination.page = 1
loadProducts()
}
})
</script>
<style lang="scss" scoped>
.products-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.page-icon {
color: #44443f;
}
.category-tag {
font-size: 14px;
padding: 6px 16px;
border-radius: 999px;
transition: all 0.2s;
&:hover {
transform: translateY(-1px);
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="favorites-page">
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 flex items-center">
<el-icon class="text-pink-500 mr-2">
<StarFilled/>
</el-icon>
我的收藏
</h1>
<p class="text-gray-600">收藏你感兴趣的商品随时回来查看</p>
</div>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="favorites.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无收藏商品">
<el-button type="primary" @click="router.push('/products')">去逛逛</el-button>
</el-empty>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="item in favorites" :key="item.id"
class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
<SafeImage
:alt="item.productName"
:clickable="true"
:src="item.productImageUrl"
img-class="w-full h-48 object-cover"
wrapper-class="w-full h-48 bg-gray-100"
@click="router.push(`/product/${item.productId}`)"
/>
<div class="p-4">
<h3 class="font-semibold mb-2 line-clamp-1">{{ item.productName }}</h3>
<p class="text-sm text-gray-500 mb-3">{{ item.productCategory || '默认分类' }}</p>
<div class="flex justify-between items-center mb-4">
<span class="text-xl font-bold text-red-500">¥{{ item.productPrice }}</span>
<span class="text-xs text-gray-400">收藏于 {{ formatTime(item.createdAt) }}</span>
</div>
<div class="flex gap-2">
<el-button class="flex-1" type="primary" @click="addToCart(item.productId)">加入购物车</el-button>
<el-button @click="toggleFavorite(item.productId)">取消收藏</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {onMounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import dayjs from 'dayjs'
import {ElMessage} from 'element-plus'
import {favoriteApi, type FavoriteItem} from '@/api/modules/favorite'
import {useCartStore} from '@/stores/cart'
import SafeImage from '@/components/common/SafeImage.vue'
const router = useRouter()
const cartStore = useCartStore()
const loading = ref(false)
const favorites = ref<FavoriteItem[]>([])
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD')
const loadFavorites = async () => {
loading.value = true
try {
const res = await favoriteApi.getList()
if (res.success) favorites.value = res.data || []
} finally {
loading.value = false
}
}
const addToCart = async (productId: number) => {
await cartStore.addToCart(productId)
}
const toggleFavorite = async (productId: number) => {
const res = await favoriteApi.toggle(productId)
if (res.success) {
ElMessage.success(res.data.favorited ? '收藏成功' : '已取消收藏')
loadFavorites()
}
}
onMounted(() => {
loadFavorites()
})
</script>
<style lang="scss" scoped>
.favorites-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="login-page min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-md w-full">
<div class="login-panel bg-white p-8">
<!-- Logo -->
<div class="text-center mb-8">
<el-icon :size="48" class="page-mark mb-4">
<Lightning/>
</el-icon>
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
<p class="text-gray-600 mt-2">登录到社区生鲜团购系统</p>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
clearable
placeholder="请输入用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
size="large"
type="password"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<div class="flex justify-between items-center w-full">
<el-checkbox v-model="loginForm.rememberMe">记住我</el-checkbox>
<el-link :underline="false" type="primary">忘记密码</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
:loading="loading"
class="w-full"
size="large"
type="primary"
@click="handleLogin"
>
</el-button>
</el-form-item>
<div class="text-center">
<span class="text-gray-600">还没有账号</span>
<router-link class="text-primary-500 hover:underline" to="/register">
立即注册
</router-link>
</div>
</el-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive} from 'vue'
import type {FormInstance, FormRules} from 'element-plus'
import {useUserStore} from '@/stores/user'
import type {LoginParams} from '@/types/api'
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const loginForm = reactive<LoginParams>({
username: '',
password: '',
rememberMe: false
})
const rules: FormRules = {
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur'}
],
password: [
{required: true, message: '请输入密码', trigger: 'blur'},
{min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur'}
]
}
// 登录
const handleLogin = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const success = await userStore.login(loginForm)
if (success) {
// 登录成功router跳转已在store中处理
}
} finally {
loading.value = false
}
}
})
}
</script>
<style lang="scss" scoped>
.login-page {
background: transparent;
}
.login-panel {
border: 1px solid #d8cebf;
border-radius: 24px;
background: #fffaf2;
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
}
.page-mark {
color: #171715;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div class="max-w-4xl mx-auto py-6 px-4">
<!-- 面包屑 -->
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>消息通知</el-breadcrumb-item>
</el-breadcrumb>
<!-- 操作栏 -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">消息通知</h2>
<div class="flex gap-2">
<el-button :disabled="unreadCount === 0" size="small" @click="handleMarkAllRead">
全部已读
</el-button>
<el-button :disabled="notifications.length === 0" plain size="small" type="danger" @click="handleClearAll">
清空全部
</el-button>
</div>
</div>
<!-- 标签筛选 -->
<el-tabs v-model="activeType" @tab-change="loadNotifications">
<el-tab-pane label="全部" name="all"/>
<el-tab-pane label="限时" name="flashsale"/>
<el-tab-pane label="订单" name="order"/>
<el-tab-pane label="系统" name="system"/>
</el-tabs>
<!-- 加载状态 -->
<div v-if="loading" class="text-center py-12">
<el-icon :size="32" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<!-- 通知列表 -->
<div v-else-if="notifications.length > 0" class="space-y-3">
<div
v-for="item in notifications"
:key="item.id"
:class="{ 'bg-orange-50/50 border-orange-200': !item.read }"
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50"
@click="handleClick(item)"
>
<div class="flex items-start gap-3">
<el-icon :class="getIconColor(item.type)" :size="20" class="mt-0.5">
<component :is="getIcon(item.type)"/>
</el-icon>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span :class="{ 'font-semibold': !item.read }" class="font-medium">{{ item.title }}</span>
<el-tag v-if="!item.read" effect="light" size="small" type="danger">未读</el-tag>
<el-tag effect="plain" size="small">{{ getTypeLabel(item.type) }}</el-tag>
</div>
<p class="text-sm text-gray-600 mb-2">{{ item.message }}</p>
<span class="text-xs text-gray-400">{{ formatTime(item.createdAt) }}</span>
</div>
<el-button
v-if="!item.read"
size="small"
text
@click.stop="handleMarkRead(item)"
>
标记已读
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty v-else class="py-12" description="暂无消息通知"/>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {notificationApi} from '@/api/modules/notification'
import type {NotificationItem} from '@/api/modules/notification'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const router = useRouter()
const loading = ref(false)
const activeType = ref('all')
const notifications = ref<NotificationItem[]>([])
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
const loadNotifications = async () => {
loading.value = true
try {
const type = activeType.value === 'all' ? undefined : activeType.value
const res = await notificationApi.getList(type)
if (res?.success) {
notifications.value = res.data || []
}
} catch {
ElMessage.error('获取通知失败')
} finally {
loading.value = false
}
}
const handleMarkRead = async (item: NotificationItem) => {
try {
await notificationApi.markAsRead(item.id)
item.read = true
} catch {
ElMessage.error('操作失败')
}
}
const handleMarkAllRead = async () => {
try {
await notificationApi.markAllAsRead()
notifications.value.forEach(n => n.read = true)
ElMessage.success('已全部标记为已读')
} catch {
ElMessage.error('操作失败')
}
}
const handleClearAll = async () => {
try {
await ElMessageBox.confirm('确定要清空所有通知吗?', '提示', {type: 'warning'})
await notificationApi.clearAll()
notifications.value = []
ElMessage.success('已清空所有通知')
} catch {
// cancelled
}
}
const handleClick = async (item: NotificationItem) => {
if (!item.read) {
await notificationApi.markAsRead(item.id).catch(() => {
})
item.read = true
}
if (item.link) {
router.push(item.link)
}
}
const formatTime = (dateStr: string) => {
return dayjs(dateStr).fromNow()
}
const getIcon = (type: string) => {
const icons: Record<string, string> = {
flashsale: 'Lightning',
order: 'List',
system: 'InfoFilled'
}
return icons[type] || 'InfoFilled'
}
const getIconColor = (type: string) => {
const colors: Record<string, string> = {
flashsale: 'text-orange-500',
order: 'text-blue-500',
system: 'text-gray-500'
}
return colors[type] || 'text-gray-500'
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
flashsale: '限时',
order: '订单',
system: '系统'
}
return labels[type] || type
}
onMounted(() => {
loadNotifications()
})
</script>

View File

@@ -0,0 +1,460 @@
<template>
<div class="profile-page">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card blue">
<div class="stat-value">{{ profileStats.totalOrders }}</div>
<div class="stat-label">总订单数</div>
</div>
<div class="stat-card green">
<div class="stat-value">¥{{ Number(profileStats.totalAmount || 0).toFixed(2) }}</div>
<div class="stat-label">累计消费</div>
</div>
<div class="stat-card orange">
<div class="stat-value">{{ profileStats.flashSaleSuccess }}</div>
<div class="stat-label">活动成功</div>
</div>
<div class="stat-card purple">
<div class="stat-value">{{ profileStats.favoriteCount }}</div>
<div class="stat-label">收藏商品</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="text-center mb-6">
<el-avatar :size="80" :src="infoForm.avatar">
{{ (userStore.username || 'U')[0] }}
</el-avatar>
<h3 class="mt-3 font-semibold">{{ userStore.username }}</h3>
<p class="text-sm text-gray-500">{{ userStore.user?.email }}</p>
</div>
<el-menu :default-active="activeMenu" @select="handleMenuSelect">
<el-menu-item index="info">
<el-icon>
<User/>
</el-icon>
<span>基本信息</span></el-menu-item>
<el-menu-item index="security">
<el-icon>
<Lock/>
</el-icon>
<span>账号安全</span></el-menu-item>
<el-menu-item index="address">
<el-icon>
<Location/>
</el-icon>
<span>收货地址</span></el-menu-item>
<el-menu-item index="orders">
<el-icon>
<List/>
</el-icon>
<span>我的订单</span></el-menu-item>
<el-menu-item index="favorites">
<el-icon>
<Star/>
</el-icon>
<span>我的收藏</span></el-menu-item>
</el-menu>
</div>
</div>
<div class="lg:col-span-3">
<div class="bg-white rounded-lg shadow-sm p-6">
<div v-if="activeMenu === 'info'">
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
<el-form ref="infoFormRef" :model="infoForm" :rules="infoRules" label-width="100px">
<el-form-item label="用户名">
<el-input v-model="infoForm.username" disabled/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="infoForm.email"/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="infoForm.phone"/>
</el-form-item>
<el-form-item label="头像">
<div class="flex items-center gap-4">
<el-avatar :size="60" :src="infoForm.avatar">{{ (infoForm.username || 'U')[0] }}</el-avatar>
<el-button @click="handleUpdateAvatar">更换头像</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveInfo">保存修改</el-button>
</el-form-item>
</el-form>
</div>
<div v-else-if="activeMenu === 'security'">
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="passwordForm.oldPassword" show-password type="password"/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" show-password type="password"/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" show-password type="password"/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChangePassword">修改密码</el-button>
</el-form-item>
</el-form>
</div>
<div v-else-if="activeMenu === 'address'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">收货地址</h2>
<el-button type="primary" @click="openAddressDialog()">
<el-icon class="mr-1">
<Plus/>
</el-icon>
添加地址
</el-button>
</div>
<div class="space-y-4">
<div v-for="addr in addresses" :key="addr.id" class="border rounded-lg p-4">
<div class="flex justify-between items-start gap-4">
<div>
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="font-semibold">{{ addr.name }}</span>
<span class="text-gray-500">{{ addr.phone }}</span>
<el-tag v-if="addr.isDefault" size="small" type="primary">默认</el-tag>
</div>
<p class="text-gray-600">{{ addr.province }} {{ addr.city }} {{ addr.district }} {{
addr.address
}}</p>
</div>
<div class="space-x-2 whitespace-nowrap">
<el-button v-if="!addr.isDefault" size="small" text type="success"
@click="setDefaultAddress(addr.id)">设为默认
</el-button>
<el-button size="small" text type="primary" @click="openAddressDialog(addr)">编辑</el-button>
<el-button size="small" text type="danger" @click="removeAddress(addr.id)">删除</el-button>
</div>
</div>
</div>
<el-empty v-if="addresses.length === 0" description="暂无收货地址"/>
</div>
</div>
<div v-else-if="activeMenu === 'orders'">
<h2 class="text-xl font-semibold mb-6">我的订单</h2>
<p class="text-gray-500">正在跳转到订单列表页...</p>
</div>
<div v-else-if="activeMenu === 'favorites'">
<h2 class="text-xl font-semibold mb-6">我的收藏</h2>
<p class="text-gray-500">正在跳转到收藏页...</p>
</div>
</div>
</div>
</div>
</div>
<el-dialog v-model="addressDialogVisible" :title="editingAddressId ? '编辑地址' : '新增地址'" width="620px">
<el-form ref="addressFormRef" :model="addressForm" :rules="addressRules" label-width="90px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="收货人" prop="name">
<el-input v-model="addressForm.name"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="addressForm.phone"/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="省份" prop="province">
<el-input v-model="addressForm.province"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="城市" prop="city">
<el-input v-model="addressForm.city"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="区县" prop="district">
<el-input v-model="addressForm.district"/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="详细地址" prop="address">
<el-input v-model="addressForm.address" :rows="3" type="textarea"/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="addressForm.isDefault">设为默认地址</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addressDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveAddress">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import {onMounted, reactive, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import type {FormInstance, FormRules} from 'element-plus'
import {useUserStore} from '@/stores/user'
import {userApi} from '@/api/modules/user'
import {addressApi, type AddressItem} from '@/api/modules/address'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const activeMenu = ref('info')
const infoFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
const addressFormRef = ref<FormInstance>()
const addressDialogVisible = ref(false)
const editingAddressId = ref<number | null>(null)
const addresses = ref<AddressItem[]>([])
const profileStats = reactive({totalOrders: 0, totalAmount: 0, flashSaleSuccess: 0, favoriteCount: 0})
const infoForm = reactive({username: '', email: '', phone: '', avatar: ''})
const passwordForm = reactive({oldPassword: '', newPassword: '', confirmPassword: ''})
const addressForm = reactive<AddressItem>({
id: 0,
name: '',
phone: '',
province: '',
city: '',
district: '',
address: '',
isDefault: false
})
const infoRules: FormRules = {
email: [{required: true, message: '请输入邮箱', trigger: 'blur'}, {
type: 'email',
message: '请输入正确的邮箱地址',
trigger: 'blur'
}],
phone: [{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur'}],
}
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (!value) callback(new Error('请再次输入密码'))
else if (value !== passwordForm.newPassword) callback(new Error('两次输入密码不一致'))
else callback()
}
const passwordRules: FormRules = {
oldPassword: [{required: true, message: '请输入原密码', trigger: 'blur'}],
newPassword: [{required: true, message: '请输入新密码', trigger: 'blur'}, {
min: 6,
max: 20,
message: '密码长度在 6 到 20 个字符',
trigger: 'blur'
}],
confirmPassword: [{required: true, validator: validatePassword, trigger: 'blur'}],
}
const addressRules: FormRules = {
name: [{required: true, message: '请输入收货人', trigger: 'blur'}],
phone: [{required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确手机号', trigger: 'blur'}],
province: [{required: true, message: '请输入省份', trigger: 'blur'}],
city: [{required: true, message: '请输入城市', trigger: 'blur'}],
district: [{required: true, message: '请输入区县', trigger: 'blur'}],
address: [{required: true, message: '请输入详细地址', trigger: 'blur'}],
}
const syncInfoForm = () => {
infoForm.username = userStore.user?.username || ''
infoForm.email = userStore.user?.email || ''
infoForm.phone = userStore.user?.phone || ''
infoForm.avatar = userStore.user?.avatar || ''
}
const syncActiveMenu = () => {
if (route.path.endsWith('/addresses')) activeMenu.value = 'address'
else activeMenu.value = (route.query.tab as string) || 'info'
}
const loadAddresses = async () => {
try {
const res = await addressApi.getList()
if (res.success) addresses.value = res.data || []
} catch (error) {
console.error('加载地址失败:', error)
}
}
const loadProfileStats = async () => {
try {
const res = await userApi.getProfileStats()
if (res.success) Object.assign(profileStats, res.data)
} catch (error) {
console.error('加载个人中心统计失败:', error)
}
}
const resetAddressForm = () => {
editingAddressId.value = null
Object.assign(addressForm, {
id: 0,
name: '',
phone: '',
province: '',
city: '',
district: '',
address: '',
isDefault: false
})
}
const handleMenuSelect = (index: string) => {
activeMenu.value = index
if (index === 'orders') router.push('/orders')
else if (index === 'favorites') router.push('/favorites')
else if (index === 'address') router.push('/addresses')
else router.push({path: '/profile', query: {tab: index}})
}
const handleSaveInfo = async () => {
if (!infoFormRef.value) return
await infoFormRef.value.validate(async (valid) => {
if (!valid) return
try {
const res = await userApi.updateInfo({email: infoForm.email, phone: infoForm.phone, avatar: infoForm.avatar})
if (res.success) {
userStore.updateUserInfo(res.data)
ElMessage.success('保存成功')
}
} catch (error) {
console.error('保存失败:', error)
}
})
}
const handleUpdateAvatar = async () => {
try {
const {value} = await ElMessageBox.prompt('请输入新的头像图片 URL', '更换头像', {
inputValue: infoForm.avatar,
confirmButtonText: '保存',
cancelButtonText: '取消'
})
infoForm.avatar = value || ''
userStore.updateUserInfo({avatar: infoForm.avatar})
ElMessage.success('头像已更新')
} catch {
}
}
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (!valid) return
try {
const res = await userApi.changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword,
confirmPassword: passwordForm.confirmPassword
})
if (res.success) {
ElMessage.success('密码修改成功,请重新登录')
await userStore.logout()
}
} catch (error) {
console.error('修改密码失败:', error)
}
})
}
const openAddressDialog = (item?: AddressItem) => {
resetAddressForm()
if (item) {
editingAddressId.value = item.id
Object.assign(addressForm, item)
}
addressDialogVisible.value = true
}
const saveAddress = async () => {
if (!addressFormRef.value) return
await addressFormRef.value.validate(async (valid) => {
if (!valid) return
const payload = {
name: addressForm.name,
phone: addressForm.phone,
province: addressForm.province,
city: addressForm.city,
district: addressForm.district,
address: addressForm.address,
isDefault: addressForm.isDefault
}
try {
if (editingAddressId.value) {
await addressApi.update(editingAddressId.value, payload)
ElMessage.success('地址已更新')
} else {
await addressApi.create(payload)
ElMessage.success('地址已新增')
}
addressDialogVisible.value = false
loadAddresses()
} catch (error) {
console.error('保存地址失败:', error)
}
})
}
const setDefaultAddress = async (id: number) => {
await addressApi.setDefault(id)
ElMessage.success('默认地址已更新')
loadAddresses()
}
const removeAddress = async (id: number) => {
await ElMessageBox.confirm('确定删除这个地址吗?', '提示', {type: 'warning'})
await addressApi.delete(id)
ElMessage.success('地址已删除')
loadAddresses()
}
watch(() => userStore.user, syncInfoForm, {immediate: true})
watch(() => route.fullPath, syncActiveMenu, {immediate: true})
onMounted(async () => {
if (userStore.token) await userStore.getUserInfo()
syncInfoForm()
syncActiveMenu()
loadAddresses()
loadProfileStats()
})
</script>
<style lang="scss" scoped>
.profile-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.stat-card {
@apply rounded-lg p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.stat-value {
@apply text-2xl font-bold;
}
.stat-label {
@apply text-sm mt-2 opacity-90;
}
</style>

View File

@@ -0,0 +1,208 @@
<template>
<div class="register-page min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-md w-full">
<div class="register-panel bg-white p-8">
<!-- Logo -->
<div class="text-center mb-8">
<el-icon :size="48" class="page-mark mb-4">
<Lightning/>
</el-icon>
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
<p class="text-gray-600 mt-2">加入社区生鲜团购系统</p>
</div>
<!-- 注册表单 -->
<el-form
ref="formRef"
:model="registerForm"
:rules="rules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
clearable
placeholder="请输入用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
clearable
placeholder="请输入邮箱"
prefix-icon="Message"
size="large"
/>
</el-form-item>
<el-form-item prop="phone">
<el-input
v-model="registerForm.phone"
clearable
placeholder="请输入手机号(选填)"
prefix-icon="Phone"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
size="large"
type="password"
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
placeholder="请确认密码"
prefix-icon="Lock"
show-password
size="large"
type="password"
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item prop="agreement">
<el-checkbox v-model="registerForm.agreement">
我已阅读并同意
<el-link :underline="false" type="primary">用户协议</el-link>
<el-link :underline="false" type="primary">隐私政策</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
:loading="loading"
class="w-full"
size="large"
type="primary"
@click="handleRegister"
>
</el-button>
</el-form-item>
<div class="text-center">
<span class="text-gray-600">已有账号</span>
<router-link class="text-primary-500 hover:underline" to="/login">
立即登录
</router-link>
</div>
</el-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElForm} from 'element-plus'
import type {FormInstance, FormRules} from 'element-plus'
import {useUserStore} from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const registerForm = reactive({
username: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
agreement: false
})
const validatePassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const validateAgreement = (rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请同意用户协议'))
} else {
callback()
}
}
const rules: FormRules = {
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur'},
{pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur'}
],
email: [
{required: true, message: '请输入邮箱', trigger: 'blur'},
{type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur'}
],
phone: [
{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur'}
],
password: [
{required: true, message: '请输入密码', trigger: 'blur'},
{min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur'}
],
confirmPassword: [
{required: true, validator: validatePassword, trigger: 'blur'}
],
agreement: [
{required: true, validator: validateAgreement, trigger: 'change'}
]
}
// 注册
const handleRegister = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const {confirmPassword, agreement, ...params} = registerForm
const success = await userStore.register(params)
if (success) {
// 注册成功跳转到登录页已在store中处理
}
} finally {
loading.value = false
}
}
})
}
</script>
<style lang="scss" scoped>
.register-page {
background: transparent;
}
.page-mark {
color: #171715;
}
.register-panel {
border: 1px solid #d8cebf;
border-radius: 24px;
background: #fffaf2;
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="user-returns-page">
<div class="container mx-auto px-4 py-8">
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>我的退货</el-breadcrumb-item>
</el-breadcrumb>
<h1 class="text-3xl font-bold mb-6">我的退货</h1>
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<el-radio-group v-model="filters.status" @change="loadReturns">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="1">待审核</el-radio-button>
<el-radio-button label="2">已同意</el-radio-button>
<el-radio-button label="3">退货中</el-radio-button>
<el-radio-button label="4">已完成</el-radio-button>
<el-radio-button label="5">已拒绝</el-radio-button>
</el-radio-group>
</div>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="returns.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无退货记录"/>
</div>
<div v-else class="space-y-4">
<div v-for="item in returns" :key="item.id" class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="bg-gray-50 px-6 py-3 flex justify-between items-center">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>退货单号{{ item.returnNo }}</span>
<span>{{ formatTime(item.createdAt) }}</span>
</div>
<el-tag :type="getReturnStatusType(item.status)">{{ item.statusText }}</el-tag>
</div>
<div class="p-6">
<div class="flex gap-4">
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-20 h-20 object-cover rounded"
wrapper-class="w-20 h-20 rounded"/>
<div class="flex-1">
<h4 class="font-semibold">{{ item.productName || '商品' }}</h4>
<div class="text-sm text-gray-500 mt-1">订单号{{ item.orderNo }}</div>
<div class="text-sm text-gray-500 mt-1">退货原因{{ item.reason }}</div>
<div v-if="item.rejectReason" class="text-sm text-red-500 mt-1">拒绝原因{{ item.rejectReason }}</div>
<div v-if="item.returnTracking" class="text-sm text-gray-500 mt-1">物流单号{{
item.returnTracking
}}
</div>
</div>
<div class="text-right">
<div class="text-sm text-gray-500">退款金额</div>
<div class="text-lg font-bold text-red-500">&yen;{{ item.refundAmount }}</div>
</div>
</div>
</div>
<div class="border-t px-6 py-4 flex justify-end gap-2">
<el-button text type="primary" @click="router.push(`/order/${item.orderId}`)">查看订单</el-button>
<el-button v-if="item.status === 'APPROVED'" size="small" type="primary" @click="openTrackingDialog(item)">
填写物流
</el-button>
<el-button v-if="item.status === 'PENDING' || item.status === 'APPROVED'" size="small"
@click="handleCancel(item)">取消退货
</el-button>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="mt-8 flex justify-center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]" :total="pagination.total" layout="total, sizes, prev, pager, next"
@size-change="loadReturns" @current-change="loadReturns"/>
</div>
<ReturnTrackingDialog
v-if="currentReturn"
v-model:visible="trackingDialogVisible"
:return-id="currentReturn.id"
@success="loadReturns"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, reactive, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {returnApi} from '@/api/modules/return'
import {normalizeOrderReturn} from '@/utils/normalizers'
import type {OrderReturn} from '@/types/api'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
const router = useRouter()
const loading = ref(false)
const returns = ref<OrderReturn[]>([])
const filters = reactive({status: ''})
const pagination = reactive({page: 1, size: 10, total: 0})
const trackingDialogVisible = ref(false)
const currentReturn = ref<OrderReturn | null>(null)
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const getReturnStatusType = (status: string) => ({
PENDING: 'warning',
APPROVED: 'primary',
RETURNING: 'primary',
COMPLETED: 'success',
REJECTED: 'danger',
CANCELLED: 'info'
}[status] || 'info')
const loadReturns = async () => {
loading.value = true
try {
const res = await returnApi.getMyReturns({
status: filters.status ? Number(filters.status) : undefined,
page: pagination.page - 1,
size: pagination.size,
})
if (res.success) {
returns.value = (res.data.content || []).map((item: any) => normalizeOrderReturn(item))
pagination.total = res.data.totalElements || 0
}
} catch (error) {
console.error('加载退货列表失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const openTrackingDialog = (item: OrderReturn) => {
currentReturn.value = item
trackingDialogVisible.value = true
}
const handleCancel = async (item: OrderReturn) => {
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
try {
await returnApi.cancel(item.id)
ElMessage.success('退货申请已取消')
loadReturns()
} catch (error) {
console.error('取消退货失败:', error)
}
}
onMounted(() => {
loadReturns()
})
</script>
<style lang="scss" scoped>
.user-returns-page {
min-height: calc(100vh - 60px);
background: transparent;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="user-reviews-page">
<div class="container mx-auto px-4 py-8">
<el-breadcrumb class="mb-6" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>我的评价</el-breadcrumb-item>
</el-breadcrumb>
<h1 class="text-3xl font-bold mb-6">我的评价</h1>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin">
<Loading/>
</el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="reviews.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无评价,去购物吧">
<el-button type="primary" @click="router.push('/products')">去购物</el-button>
</el-empty>
</div>
<div v-else class="space-y-4">
<div v-for="review in paginatedReviews" :key="review.id" class="bg-white rounded-lg shadow-sm p-6">
<div class="flex gap-4">
<SafeImage
:alt="review.productName"
:src="review.productImage"
img-class="w-20 h-20 object-cover rounded"
wrapper-class="w-20 h-20 rounded cursor-pointer"
@click="router.push(`/product/${review.productId}`)"
/>
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<div>
<h3
class="font-semibold cursor-pointer hover:text-blue-500 inline"
@click="router.push(`/product/${review.productId}`)"
>
{{ review.productName || '商品' }}
</h3>
<span
v-if="review.orderId"
class="ml-3 text-xs text-gray-400 cursor-pointer hover:text-blue-400"
@click="router.push(`/order/${review.orderId}`)"
>
订单 #{{ review.orderId }}
</span>
</div>
<span class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</span>
</div>
<el-rate :model-value="review.rating" disabled/>
<p class="text-gray-600 mt-2 leading-6">{{ review.content }}</p>
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm">
<div class="font-medium text-gray-800 mb-1">商家回复</div>
<div class="text-gray-600">{{ review.adminReply }}</div>
</div>
</div>
</div>
</div>
<div v-if="reviews.length > pageSize" class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="reviews.length"
layout="prev, pager, next"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, onMounted} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {reviewApi} from '@/api/modules/review'
import type {ReviewItem} from '@/api/modules/review'
import dayjs from 'dayjs'
import SafeImage from '@/components/common/SafeImage.vue'
const router = useRouter()
const loading = ref(false)
const reviews = ref<ReviewItem[]>([])
const currentPage = ref(1)
const pageSize = 10
const paginatedReviews = computed(() => {
const start = (currentPage.value - 1) * pageSize
return reviews.value.slice(start, start + pageSize)
})
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
const loadReviews = async () => {
loading.value = true
try {
const res = await reviewApi.getMyReviews()
if (res.success) {
reviews.value = res.data
}
} catch (error) {
console.error('加载评价失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadReviews()
})
</script>
<style lang="scss" scoped>
.user-reviews-page {
min-height: calc(100vh - 60px);
background: transparent;
}
</style>

View File

@@ -0,0 +1,64 @@
import type {Router} from 'vue-router'
import {useUserStore} from '@/stores/user'
import {ElMessage} from 'element-plus'
export function setupGuards(router: Router) {
// 路由前置守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
if (userStore.token && !userStore.user) {
await userStore.getUserInfo()
}
// 设置页面标题
document.title = `${to.meta.title || '社区生鲜团购系统'} - 社区生鲜团购平台`
// 需要登录的页面
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
ElMessage.warning('请先登录')
next({
path: '/login',
query: {redirect: to.fullPath}
})
return
}
// 需要管理员权限的页面
if (to.meta.requiresAdmin && !userStore.isAdmin) {
ElMessage.error('无权访问')
next('/')
return
}
const adminBlockedFrontPaths = [
'/cart',
'/orders',
'/favorites',
'/reviews',
'/returns',
'/notifications',
'/addresses',
'/profile',
]
const isAdminFrontPath = adminBlockedFrontPaths.some((path) => to.path === path || to.path.startsWith(`${path}/`))
if (userStore.isAdmin && isAdminFrontPath) {
next('/admin')
return
}
// 已登录用户访问登录/注册页面
if ((to.path === '/login' || to.path === '/register') && userStore.isLoggedIn) {
next('/')
return
}
next()
})
// 路由后置守卫
router.afterEach((to, from) => {
// 页面切换后滚动到顶部
window.scrollTo(0, 0)
})
}

View File

@@ -0,0 +1,229 @@
import {createRouter, createWebHistory} from 'vue-router'
import type {RouteRecordRaw} from 'vue-router'
import {setupGuards} from './guards'
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{
path: '',
name: 'Home',
component: () => import('@/pages/home/index.vue'),
meta: {title: '首页'}
},
{
path: 'flashsale',
name: 'FlashSale',
component: () => import('@/pages/flashsale/index.vue'),
meta: {title: '限时活动'}
},
{
path: 'flashsales',
redirect: '/flashsale'
},
{
path: 'flashsale/:id',
name: 'FlashSaleDetail',
component: () => import('@/pages/flashsale/detail.vue'),
meta: {title: '限时详情'}
},
{
path: 'products',
name: 'Products',
component: () => import('@/pages/product/index.vue'),
meta: {title: '商品列表'}
},
{
path: 'search',
redirect: (to) => ({path: '/products', query: to.query})
},
{
path: 'category/:category',
redirect: (to) => ({path: '/products', query: {category: String(to.params.category || '')}})
},
{
path: 'product/:id',
name: 'ProductDetail',
component: () => import('@/pages/product/detail.vue'),
meta: {title: '商品详情'}
},
{
path: 'cart',
name: 'Cart',
component: () => import('@/pages/cart/index.vue'),
meta: {title: '购物车', requiresAuth: true}
},
{
path: 'orders',
name: 'Orders',
component: () => import('@/pages/order/index.vue'),
meta: {title: '我的订单', requiresAuth: true}
},
{
path: 'order/:id',
name: 'OrderDetail',
component: () => import('@/pages/order/detail.vue'),
meta: {title: '订单详情', requiresAuth: true}
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/pages/user/profile.vue'),
meta: {title: '个人中心', requiresAuth: true}
},
{
path: 'favorites',
name: 'Favorites',
component: () => import('@/pages/user/favorites.vue'),
meta: {title: '我的收藏', requiresAuth: true}
},
{
path: 'reviews',
name: 'MyReviews',
component: () => import('@/pages/user/reviews.vue'),
meta: {title: '我的评价', requiresAuth: true}
},
{
path: 'returns',
name: 'MyReturns',
component: () => import('@/pages/user/returns.vue'),
meta: {title: '我的退货', requiresAuth: true}
},
{
path: 'notifications',
name: 'Notifications',
component: () => import('@/pages/user/notifications.vue'),
meta: {title: '消息通知', requiresAuth: true}
},
{
path: 'groupbuying',
name: 'GroupBuying',
component: () => import('@/pages/groupbuying/index.vue'),
meta: {title: '拼团活动'}
},
{
path: 'groupbuying/:id',
name: 'GroupBuyingDetail',
component: () => import('@/pages/groupbuying/detail.vue'),
meta: {title: '拼团详情'}
},
{
path: 'groupbuying/group/:id',
name: 'GroupBuyingGroupDetail',
component: () => import('@/pages/groupbuying/group.vue'),
meta: {title: '团组详情', requiresAuth: true}
},
{
path: 'addresses',
name: 'Addresses',
component: () => import('@/pages/user/profile.vue'),
meta: {title: '地址管理', requiresAuth: true}
}
]
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/user/login.vue'),
meta: {title: '登录'}
},
{
path: '/register',
name: 'Register',
component: () => import('@/pages/user/register.vue'),
meta: {title: '注册'}
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: {requiresAuth: true, requiresAdmin: true},
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/pages/admin/dashboard.vue'),
meta: {title: '管理后台'}
},
{
path: 'products',
name: 'AdminProducts',
component: () => import('@/pages/admin/products.vue'),
meta: {title: '商品管理'}
},
{
path: 'flashsales',
name: 'AdminFlashSales',
component: () => import('@/pages/admin/flashsales.vue'),
meta: {title: '限时管理'}
},
{
path: 'groupbuying',
name: 'AdminGroupBuying',
component: () => import('@/pages/admin/groupbuying.vue'),
meta: {title: '拼团管理'}
},
{
path: 'orders',
name: 'AdminOrders',
component: () => import('@/pages/admin/orders.vue'),
meta: {title: '订单管理'}
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/pages/admin/users.vue'),
meta: {title: '用户管理'}
},
{
path: 'reviews',
name: 'AdminReviews',
component: () => import('@/pages/admin/reviews.vue'),
meta: {title: '评价管理'}
},
{
path: 'returns',
name: 'AdminReturns',
component: () => import('@/pages/admin/returns.vue'),
meta: {title: '退货管理'}
},
{
path: 'favorites',
name: 'AdminFavorites',
component: () => import('@/pages/admin/favorites.vue'),
meta: {title: '收藏管理'}
},
{
path: 'monitor',
name: 'AdminMonitor',
component: () => import('@/pages/admin/monitor.vue'),
meta: {title: '系统监控'}
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/pages/error/404.vue'),
meta: {title: '页面未找到'}
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return {top: 0}
}
}
})
// 设置路由守卫
setupGuards(router)
export default router

View File

@@ -0,0 +1,167 @@
import {defineStore} from 'pinia'
import {ref, computed} from 'vue'
import type {CartItem} from '@/types/api'
import {cartApi} from '@/api/modules/cart'
import {ElMessage} from 'element-plus'
export const useCartStore = defineStore('cart', () => {
// 状态
const items = ref<CartItem[]>([])
const loading = ref(false)
// 计算属性
const itemCount = computed(() => items.value.length)
const totalQuantity = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const selectedItems = computed(() =>
items.value.filter(item => item.selected)
)
const selectedCount = computed(() => selectedItems.value.length)
const totalPrice = computed(() =>
selectedItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const isAllSelected = computed(() =>
items.value.length > 0 && items.value.every(item => item.selected)
)
// 获取购物车数据
const fetchCart = async () => {
loading.value = true
try {
const res = await cartApi.getCart()
if (res.success) {
items.value = res.data || []
}
} catch (error) {
console.error('获取购物车失败:', error)
} finally {
loading.value = false
}
}
// 添加到购物车
const addToCart = async (productId: number, quantity = 1) => {
try {
const res = await cartApi.addToCart({productId, quantity})
if (res.success) {
ElMessage.success('已添加到购物车')
await fetchCart()
return true
}
return false
} catch (error) {
console.error('添加到购物车失败:', error)
return false
}
}
// 更新数量
const updateQuantity = async (itemId: string, quantity: number) => {
if (quantity < 1) return
try {
const res = await cartApi.updateQuantity(itemId, quantity)
if (res.success) {
const item = items.value.find(i => i.id === itemId)
if (item) {
item.quantity = quantity
}
}
} catch (error) {
console.error('更新数量失败:', error)
}
}
// 删除商品
const removeItem = async (itemId: string) => {
try {
const res = await cartApi.removeItem(itemId)
if (res.success) {
items.value = items.value.filter(i => i.id !== itemId)
ElMessage.success('已删除')
}
} catch (error) {
console.error('删除失败:', error)
}
}
// 批量删除
const removeSelected = async () => {
const ids = selectedItems.value.map(item => item.id)
if (ids.length === 0) return
try {
const res = await cartApi.batchRemove(ids)
if (res.success) {
items.value = items.value.filter(i => !ids.includes(i.id))
ElMessage.success('已批量删除')
}
} catch (error) {
console.error('批量删除失败:', error)
}
}
// 清空购物车
const clearCart = async () => {
try {
const res = await cartApi.clearCart()
if (res.success) {
items.value = []
ElMessage.success('购物车已清空')
}
} catch (error) {
console.error('清空购物车失败:', error)
}
}
// 切换选中状态
const toggleSelect = (itemId: string) => {
const item = items.value.find(i => i.id === itemId)
if (item) {
item.selected = !item.selected
}
}
// 全选/取消全选
const toggleSelectAll = () => {
const allSelected = isAllSelected.value
items.value.forEach(item => {
item.selected = !allSelected
})
}
// 获取购物车数量(仅数量)
const getCartCount = async () => {
try {
const res = await cartApi.getCount()
if (res.success) {
return res.data.count || 0
}
return 0
} catch (error) {
console.error('获取购物车数量失败:', error)
return 0
}
}
return {
items,
loading,
itemCount,
totalQuantity,
selectedItems,
selectedCount,
totalPrice,
isAllSelected,
fetchCart,
addToCart,
updateQuantity,
removeItem,
removeSelected,
clearCart,
toggleSelect,
toggleSelectAll,
getCartCount,
}
})

View File

@@ -0,0 +1,159 @@
import {defineStore} from 'pinia'
import {ref, computed} from 'vue'
import type {User, LoginParams, RegisterParams} from '@/types/api'
import {userApi} from '@/api/modules/user'
import {ElMessage} from 'element-plus'
import router from '@/router'
export const useUserStore = defineStore('user', () => {
// 状态
const user = ref<User | null>(null)
const token = ref<string>('')
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'ADMIN' || user.value?.username === 'admin')
const username = computed(() => user.value?.username || '')
// 从localStorage恢复登录状态
const initializeAuth = () => {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken && savedUser) {
token.value = savedToken
user.value = JSON.parse(savedUser)
}
}
// 登录
const login = async (params: LoginParams) => {
try {
const res = await userApi.login(params)
if (res.success) {
token.value = res.data.token
user.value = res.data.user
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
try {
const profile = await userApi.getInfo()
if (profile.success) {
user.value = {
...profile.data,
avatar: profile.data.avatar || user.value?.avatar || '',
}
localStorage.setItem('user', JSON.stringify(user.value))
}
} catch (sessionError) {
console.error('登录成功但会话校验失败:', sessionError)
user.value = null
token.value = ''
localStorage.removeItem('token')
localStorage.removeItem('user')
ElMessage.error('登录成功但会话未建立,请检查 Cookie / 代理配置')
return false
}
ElMessage.success('登录成功')
const redirect = router.currentRoute.value.query.redirect as string
await router.push(redirect || '/')
return true
}
return false
} catch (error) {
console.error('登录失败:', error)
return false
}
}
// 注册
const register = async (params: RegisterParams) => {
try {
const res = await userApi.register(params)
if (res.success) {
ElMessage.success('注册成功,请登录')
router.push('/login')
return true
}
return false
} catch (error) {
console.error('注册失败:', error)
return false
}
}
// 退出登录
const logout = async () => {
try {
if (token.value) {
await userApi.logout()
}
} catch (error) {
console.error('退出登录失败:', error)
}
user.value = null
token.value = ''
// 清除localStorage
localStorage.removeItem('token')
localStorage.removeItem('user')
ElMessage.success('已退出登录')
router.push('/login')
}
// 获取用户信息
const getUserInfo = async () => {
if (!token.value) return
try {
const res = await userApi.getInfo()
if (res.success) {
user.value = {
...res.data,
avatar: res.data.avatar || user.value?.avatar || '',
}
localStorage.setItem('user', JSON.stringify(user.value))
}
} catch (error) {
console.error('获取用户信息失败:', error)
user.value = null
token.value = ''
localStorage.removeItem('token')
localStorage.removeItem('user')
}
}
// 更新用户信息
const updateUserInfo = (info: Partial<User>) => {
if (user.value) {
user.value = {...user.value, ...info}
localStorage.setItem('user', JSON.stringify(user.value))
}
}
// 初始化
initializeAuth()
return {
user,
token,
isLoggedIn,
isAdmin,
username,
login,
register,
logout,
getUserInfo,
updateUserInfo,
}
})

View File

@@ -0,0 +1,514 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--app-header-height: 72px;
--tone-0: #fffdf8;
--tone-50: #f7f2ea;
--tone-100: #efe7dc;
--tone-200: #d8cebf;
--tone-300: #c4b7a4;
--tone-400: #9a8b76;
--tone-500: #746855;
--tone-600: #5c5346;
--tone-700: #433d34;
--tone-800: #2d2a25;
--tone-900: #171614;
--surface-muted: #f4ede4;
--surface-raised: #fffaf2;
--line-soft: #d8cebf;
--line-strong: #171614;
--shadow-soft: 0 14px 34px rgba(23, 22, 20, 0.06);
--shadow-strong: 0 18px 40px rgba(23, 22, 20, 0.1);
--radius-xl: 24px;
--radius-lg: 20px;
--radius-md: 16px;
--primary-color: var(--tone-900);
--success-color: var(--tone-700);
--warning-color: var(--tone-600);
--danger-color: var(--tone-900);
--info-color: var(--tone-500);
--el-color-primary: var(--tone-900);
--el-color-primary-light-3: var(--tone-700);
--el-color-primary-light-5: var(--tone-600);
--el-color-primary-light-7: var(--tone-400);
--el-color-primary-light-8: var(--tone-300);
--el-color-primary-light-9: var(--tone-100);
--el-color-primary-dark-2: #0f0f0d;
--el-color-success: var(--tone-700);
--el-color-success-light-9: var(--tone-100);
--el-color-warning: var(--tone-600);
--el-color-warning-light-9: var(--tone-100);
--el-color-danger: var(--tone-900);
--el-color-danger-light-9: var(--tone-100);
--el-color-info: var(--tone-500);
--el-color-info-light-9: var(--tone-100);
--el-border-color: var(--line-soft);
--el-border-color-light: var(--line-soft);
--el-border-color-lighter: var(--tone-100);
--el-fill-color-light: var(--surface-muted);
--el-fill-color-blank: var(--tone-0);
--el-bg-color: var(--tone-0);
--el-bg-color-page: var(--tone-50);
--el-text-color-primary: var(--tone-900);
--el-text-color-regular: var(--tone-700);
--el-text-color-secondary: var(--tone-500);
--el-text-color-placeholder: var(--tone-400);
--el-mask-color: rgba(23, 23, 21, 0.52);
--el-box-shadow-light: var(--shadow-soft);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
background: var(--tone-50);
}
body {
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--tone-900);
background: radial-gradient(circle at top, rgba(255, 253, 248, 0.88), rgba(255, 253, 248, 0) 26%),
linear-gradient(180deg, var(--tone-50) 0%, #f2ebdf 100%);
}
#app {
min-height: 100vh;
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--tone-50);
}
::-webkit-scrollbar-thumb {
background: #b8ab90;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #8c7e6b;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-2px);
}
20%, 40%, 60%, 80% {
transform: translateX(2px);
}
}
.shake {
animation: shake 0.5s;
}
.text-gradient {
color: var(--tone-900);
}
.card-shadow {
border: 1px solid var(--line-soft);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
&:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-strong);
transform: translateY(-2px);
}
}
:where(.el-button, .el-input__wrapper, .el-select__wrapper, .el-textarea__inner, .el-dialog,
.el-card, .el-popover, .el-message-box, .el-notification, .el-alert, .el-tag, .el-table,
.el-empty, .el-menu-item, .el-sub-menu__title, .el-radio-button__inner, .el-pagination button) {
transition: all 0.2s ease;
}
.el-button {
border-radius: 999px !important;
font-weight: 600;
box-shadow: none !important;
border-width: 1px !important;
border-style: solid !important;
border-color: var(--line-strong) !important;
background: var(--surface-raised) !important;
color: var(--tone-900) !important;
}
.el-button--primary,
.el-button--danger {
background-color: var(--surface-raised) !important;
border-color: var(--line-strong) !important;
color: var(--tone-900) !important;
}
.el-button:hover,
.el-button:focus,
.el-button--primary:hover,
.el-button--primary:focus,
.el-button--danger:hover,
.el-button--danger:focus {
background-color: var(--tone-900) !important;
border-color: var(--tone-900) !important;
color: #ffffff !important;
}
.el-button.is-text,
.el-button--text {
color: var(--tone-900) !important;
background: transparent !important;
border-color: transparent !important;
padding-left: 4px !important;
padding-right: 4px !important;
}
.el-button.is-text:hover,
.el-button--text:hover {
background: transparent !important;
color: var(--tone-700) !important;
}
.el-button--default:hover,
.el-button--default:focus {
background: var(--tone-50) !important;
color: var(--tone-900) !important;
border-color: var(--line-strong) !important;
}
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner,
.el-date-editor.el-input__wrapper,
.el-date-editor .el-input__wrapper {
background: var(--surface-raised) !important;
border-radius: 14px !important;
box-shadow: inset 0 0 0 1px var(--line-soft) !important;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow: inset 0 0 0 1px var(--line-strong) !important;
}
.el-radio-button__inner {
border-radius: 14px !important;
border-color: var(--line-soft) !important;
color: var(--tone-900) !important;
background: var(--surface-raised) !important;
box-shadow: none !important;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background: var(--tone-0) !important;
border-color: var(--line-strong) !important;
color: var(--tone-900) !important;
box-shadow: inset 0 0 0 1px var(--tone-900) !important;
}
.el-card,
.el-dialog,
.el-popover,
.el-message-box,
.el-notification {
border: 1px solid var(--line-soft) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-soft) !important;
background: var(--surface-raised) !important;
overflow: hidden;
}
.el-dialog__header,
.el-message-box__header {
margin: 0;
padding-bottom: 12px;
}
.el-dialog__body {
color: var(--tone-700);
}
.el-table {
--el-table-border-color: var(--line-soft);
--el-table-header-bg-color: var(--surface-muted);
--el-table-row-hover-bg-color: var(--tone-50);
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--line-soft);
background: var(--surface-raised);
}
.el-table th.el-table__cell {
background: var(--surface-muted);
color: var(--tone-900);
font-weight: 600;
}
.el-table tr {
color: var(--tone-800);
}
.el-tag,
.el-tag--success,
.el-tag--warning,
.el-tag--danger,
.el-tag--info,
.el-tag--primary {
background: var(--surface-raised) !important;
border-color: var(--line-soft) !important;
color: var(--tone-700) !important;
border-radius: 999px !important;
}
.el-alert,
.el-alert--success,
.el-alert--warning,
.el-alert--error,
.el-alert--info {
background: var(--surface-raised) !important;
border: 1px solid var(--line-soft) !important;
color: var(--tone-900) !important;
}
.el-tabs__item {
color: var(--tone-500) !important;
}
.el-tabs__item.is-active,
.el-tabs__item:hover {
color: var(--tone-900) !important;
}
.el-tabs__active-bar {
background: var(--tone-900) !important;
}
.el-progress-bar__outer {
background: var(--surface-muted) !important;
box-shadow: inset 0 0 0 1px var(--line-soft) !important;
}
.el-progress-bar__inner {
background: var(--tone-900) !important;
}
.el-menu {
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: var(--surface-raised);
--el-menu-active-color: var(--tone-900);
border-right: none !important;
}
.el-sub-menu__title:hover,
.el-menu-item:hover {
background: var(--surface-muted) !important;
}
.el-menu-item.is-active {
background: var(--surface-raised) !important;
color: var(--tone-900) !important;
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.el-pagination {
--el-pagination-button-bg-color: transparent;
--el-pagination-hover-color: var(--tone-900);
}
.el-pagination .btn-prev,
.el-pagination .btn-next,
.el-pagination .el-pager li {
border-radius: 999px;
border: 1px solid var(--line-soft);
background: var(--surface-raised);
}
.el-pagination .el-pager li.is-active {
background: var(--surface-raised) !important;
color: var(--tone-900) !important;
font-weight: 700;
border-color: var(--line-strong);
}
.el-badge__content {
background: var(--surface-raised) !important;
border-color: var(--line-strong) !important;
color: var(--tone-900) !important;
}
.el-breadcrumb__inner.is-link,
.el-breadcrumb__inner a {
color: var(--tone-500) !important;
}
.el-breadcrumb__inner {
color: var(--tone-700) !important;
}
.el-step__title.is-process,
.el-step__title.is-finish,
.el-step__icon.is-process,
.el-step__icon.is-finish {
color: var(--tone-900) !important;
border-color: var(--tone-900) !important;
}
.el-step__head.is-process,
.el-step__head.is-finish,
.el-step__line-inner {
border-color: var(--tone-900) !important;
background-color: var(--tone-900) !important;
}
.el-empty__description p {
color: var(--tone-500) !important;
}
.el-rate__icon.is-active {
color: var(--tone-800) !important;
}
.text-red-500,
.text-blue-500,
.text-green-500,
.text-orange-500,
.text-purple-500,
.text-pink-500,
.text-rose-500,
.text-yellow-500,
.text-emerald-500,
.text-blue-700,
.text-blue-900 {
color: var(--tone-700) !important;
}
.bg-red-50,
.bg-blue-50,
.bg-yellow-50,
.bg-orange-50,
.bg-green-50,
.bg-purple-50,
.bg-pink-50,
.bg-rose-50,
.bg-emerald-50,
.bg-blue-100,
.bg-emerald-100,
.bg-orange-100,
.bg-rose-100 {
background-color: var(--surface-muted) !important;
}
.from-red-500,
.from-orange-400,
.from-green-400,
.from-purple-400,
.from-yellow-400,
.from-blue-500,
.from-blue-400,
.from-pink-500 {
--tw-gradient-from: #ffffff var(--tw-gradient-from-position) !important;
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position) !important;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
}
.to-red-500,
.to-blue-500,
.to-pink-500,
.to-orange-500,
.to-blue-400,
.to-orange-400 {
--tw-gradient-to: #ffffff var(--tw-gradient-to-position) !important;
}
.border-primary-500 {
border-color: var(--tone-900) !important;
}
.hover\:text-primary-500:hover,
.hover\:text-blue-500:hover,
.hover\:text-red-500:hover {
color: var(--tone-900) !important;
}
.mini-stat,
.stat-card,
.panel-card,
.feature-card,
.price-card,
.note-card,
.rules-card,
.service-row,
.business-item,
.log-row,
.brand-icon,
.brand-tag,
.cart-link,
.user-trigger,
.notification-center .notification-trigger,
.discount-badge,
.discount-pill,
.time-block {
background: var(--surface-raised) !important;
color: var(--tone-900) !important;
border: 1px solid var(--line-soft) !important;
box-shadow: var(--shadow-soft) !important;
border-radius: var(--radius-lg) !important;
overflow: hidden;
}
.mini-stat__value,
.mini-stat__label,
.stat-value,
.stat-label,
.stat-desc,
.panel-title,
.panel-subtitle {
color: var(--tone-900) !important;
opacity: 1 !important;
}
.stat-icon {
background: var(--surface-muted) !important;
color: var(--tone-900) !important;
border: 1px solid var(--line-soft) !important;
}
.log-time {
color: var(--tone-500) !important;
}
.search-highlight {
color: var(--tone-900);
font-weight: 700;
}

View File

@@ -0,0 +1,165 @@
export interface AdminDashboardStats {
totalUsers: number
totalProducts: number
totalOrders: number
totalAmount: number
todayOrders: number
paidOrders: number
pendingOrders: number
activeFlashSales: number
}
export interface AdminUserStats {
totalUsers: number
activeUsers: number
newUsers: number
onlineUsers: number
}
export interface AdminOrderStats {
totalOrders: number
paidOrders: number
pendingOrders: number
completedOrders: number
cancelledOrders: number
totalAmount: number
}
export interface AdminProductStats {
totalProducts: number
activeProducts: number
inactiveProducts: number
lowStockProducts: number
}
export interface AdminFlashSaleStats {
totalFlashSales: number
activeFlashSales: number
upcomingFlashSales: number
endedFlashSales: number
}
export interface AdminRecentOrderRow {
id: number
orderNo: string
username: string
productName: string
quantity: number
totalAmount: number
status: string
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
createdAt: string
isFlashSale: boolean
}
export interface AdminHotProductRow {
id: number
name: string
price: number
stock: number
sales: number
}
export interface AdminUserRow {
id: number
username: string
email: string
phone: string
status: number
statusText: string
role: 'USER' | 'ADMIN'
isOnline: boolean
createdAt: string
lastLogin?: string
}
export interface AdminOrderRow {
id: number
orderNo: string
username: string
productName: string
productId?: number
quantity: number
totalAmount: number
status: string
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
createdAt: string
isFlashSale: boolean
}
export interface AdminProductRow {
id: number
name: string
description: string
category: string
price: number
stock: number
status: number
imageUrl: string
createdAt: string
updatedAt?: string
totalSales?: number
totalRevenue?: number
viewCount?: number
rating?: number
}
export interface MonitorSystemStatus {
status: string
cpuUsage: number
memoryUsage: number
diskUsage: number
availableProcessors?: number
totalMemory?: string
usedMemory?: string
dbStatus?: string
redisStatus?: string
requestCountToday?: number
}
export interface RedisNodeStatus {
node: string
status: string
memory: string
connections: number
}
export interface AdminReviewStats {
totalReviews: number
todayReviews: number
averageRating: number
fiveStarReviews: number
}
export interface AdminFavoriteStats {
totalFavorites: number
favoriteUsers: number
favoriteProducts: number
todayFavorites: number
}
export interface AdminReviewRow {
id: number
productId: number
userId: number
orderId: number
productName: string
username: string
rating: number
content: string
status: number
statusText: string
adminReply?: string
repliedAt?: string
createdAt: string
}
export interface AdminFavoriteRow {
id: number
userId: number
productId: number
productName: string
productCategory: string
username: string
createdAt: string
}

View File

@@ -0,0 +1,247 @@
// API响应基础类型
export interface ApiResponse<T = any> {
code: number
success: boolean
message: string
data: T
timestamp?: number
}
// 分页参数
export interface PageParams {
page: number
size: number
sort?: string
order?: 'asc' | 'desc'
}
// 分页响应
export interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
first: boolean
last: boolean
}
// 用户类型
export interface User {
id: number
username: string
email: string
phone?: string
avatar?: string
role: 'USER' | 'ADMIN'
status: 'ACTIVE' | 'INACTIVE' | 'BANNED'
createdAt: string
updatedAt: string
}
// 登录参数
export interface LoginParams {
username: string
password: string
rememberMe?: boolean
}
// 注册参数
export interface RegisterParams {
username: string
password: string
email: string
phone?: string
}
// 商品类型
export interface Product {
id: number
name: string
description: string
price: number
stock: number
imageUrl: string
images?: string[]
category: string
status: 'ON_SALE' | 'OFF_SALE' | 'SOLD_OUT'
sales: number
views: number
createdAt: string
updatedAt: string
}
// 限时活动类型
export interface FlashSale {
id: number
productId: number
productName: string
productImageUrl: string
originalPrice: number
flashPrice: number
flashStock: number
remainingStock: number
startTime: string
endTime: string
status: 'UPCOMING' | 'ACTIVE' | 'ENDED' | 'PAUSED'
limitPerUser: number
description?: string
createdAt: string
updatedAt: string
}
// 购物车项
export interface CartItem {
id: string
productId: number
productName: string
productImage: string
price: number
quantity: number
stock: number
selected: boolean
createdAt: string
}
// 订单类型
export interface Order {
id: number
orderNo: string
userId: number
username: string
totalAmount: number
paymentAmount: number
paymentMethod?: string
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED'
orderType?: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
items: OrderItem[]
address?: OrderAddress
remark?: string
createdAt: string
updatedAt: string
paidAt?: string
shippedAt?: string
completedAt?: string
}
// 订单项
export interface OrderItem {
id: number
productId: number
productName: string
productImage: string
price: number
quantity: number
subtotal: number
}
// 订单地址
export interface OrderAddress {
name: string
phone: string
province: string
city: string
district: string
address: string
zipCode?: string
}
// 统计数据
export interface Statistics {
totalUsers: number
totalProducts: number
totalOrders: number
totalSales: number
todayOrders: number
todaySales: number
activeFlashSales: number
onlineUsers: number
}
// 退货类型
export interface OrderReturn {
id: number
returnNo: string
orderId: number
orderNo: string
userId: number
username: string
refundAmount: number
reason: string
description?: string
images?: string
status: 'PENDING' | 'APPROVED' | 'RETURNING' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
statusText: string
rejectReason?: string
adminRemark?: string
returnTracking?: string
productName?: string
productImage?: string
reviewedAt?: string
shippedAt?: string
completedAt?: string
cancelledAt?: string
createdAt: string
updatedAt: string
}
// 拼团活动类型
export interface GroupBuying {
id: number
productId: number
productName: string
productImageUrl: string
productPrice: number
groupPrice: number
requiredMembers: number
durationMinutes: number
totalStock: number
remainingStock: number
maxPerUser: number
status: 'DRAFT' | 'UPCOMING' | 'ACTIVE' | 'ENDED'
statusDescription: string
startTime: string
endTime: string
createdAt: string
updatedAt: string
activeGroupCount: number
discount: number
}
// 拼团团组类型
export interface GroupBuyingGroup {
id: number
groupNo: string
groupBuyingId: number
leaderUserId: number
leaderUsername: string
requiredMembers: number
currentMembers: number
status: 'FORMING' | 'SUCCESS' | 'FAILED'
statusDescription: string
expireTime: string
createdAt: string
completedAt?: string
members: GroupBuyingMember[]
groupBuying?: GroupBuying
}
// 拼团成员类型
export interface GroupBuyingMember {
id: number
userId: number
username: string
avatar?: string
orderId?: number
status: number
joinedAt: string
}
// 拼团统计
export interface GroupBuyingStatistics {
totalActivities: number
activeActivities: number
myGroups: number
successGroups: number
totalSaved: number
}

View File

@@ -0,0 +1,29 @@
export interface FlashSale {
id: number
productId: number
productName: string
productImage: string
originalPrice: number
flashPrice: number
flashStock: number
soldCount: number
startTime: string
endTime: string
status: number // 0-未开始 1-进行中 2-已结束
description?: string
createdAt: string
updatedAt: string
}
export interface FlashSaleParams {
status?: number
keyword?: string
sort?: string
page?: number
size?: number
}
export interface FlashSaleParticipation {
flashSaleId: number
quantity: number
}

View File

@@ -0,0 +1,37 @@
export interface Product {
id: number
name: string
description: string
price: number
stock: number
image: string
category: string
categoryId: number
brand?: string
specifications?: Record<string, any>
sales: number
rating: number
reviewCount: number
status: number // 0-下架 1-上架
createdAt: string
updatedAt: string
}
export interface ProductParams {
categoryId?: number
keyword?: string
minPrice?: number
maxPrice?: number
sort?: string
page?: number
size?: number
}
export interface Category {
id: number
name: string
parentId?: number
icon?: string
sort: number
children?: Category[]
}

View File

@@ -0,0 +1,66 @@
import defaultProductImage from '@/assets/default-product.svg'
export const DEFAULT_PRODUCT_IMAGE = defaultProductImage
const ABSOLUTE_URL_PATTERN = /^(https?:)?\/\//i
const SPECIAL_URL_PATTERN = /^(data:|blob:)/i
const normalizeBaseUrl = (value?: string) => {
if (!value) return ''
return value.endsWith('/') ? value.slice(0, -1) : value
}
export const resolveImageUrl = (value?: string | null) => {
if (!value || !String(value).trim()) {
return DEFAULT_PRODUCT_IMAGE
}
const imageUrl = String(value).trim()
if (ABSOLUTE_URL_PATTERN.test(imageUrl) || SPECIAL_URL_PATTERN.test(imageUrl)) {
return imageUrl
}
const baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL)
if (!baseUrl) {
return imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`
}
return imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}`
}
export const normalizeStorageImageUrl = (value?: string | null) => {
if (!value || !String(value).trim()) {
return ''
}
const imageUrl = String(value).trim()
if (ABSOLUTE_URL_PATTERN.test(imageUrl)) {
try {
const parsed = new URL(imageUrl.startsWith('//') ? `http:${imageUrl}` : imageUrl)
if (
parsed.pathname.startsWith('/uploads/') ||
parsed.pathname.startsWith('/images/') ||
parsed.pathname.startsWith('/static/')
) {
return parsed.pathname
}
} catch {
return imageUrl
}
}
return imageUrl
}
export const applyFallbackImage = (event: Event) => {
const target = event.target as HTMLImageElement | null
if (!target) return
if (target.dataset.fallbackApplied === 'true') {
return
}
target.dataset.fallbackApplied = 'true'
target.onerror = null
target.src = DEFAULT_PRODUCT_IMAGE
}

View File

@@ -0,0 +1,407 @@
import type {
CartItem,
FlashSale,
GroupBuying,
GroupBuyingGroup,
Order,
OrderAddress,
OrderReturn,
PageResponse,
Product,
User,
} from '@/types/api'
import type {
AdminHotProductRow,
AdminOrderRow,
AdminProductRow,
AdminRecentOrderRow,
AdminUserRow,
} from '@/types/admin'
import {DEFAULT_PRODUCT_IMAGE, normalizeStorageImageUrl, resolveImageUrl} from '@/utils/image'
const toNumber = (value: unknown, fallback = 0) => {
const result = Number(value)
return Number.isFinite(result) ? result : fallback
}
const toString = (value: unknown, fallback = '') => {
if (value === null || value === undefined) {
return fallback
}
return String(value)
}
const toIsoLikeString = (value: unknown) => {
const raw = toString(value)
return raw || new Date().toISOString()
}
export const buildOrderNo = (id: number | string) => {
const numericId = toString(id).padStart(6, '0')
return `FS${numericId}`
}
export const mapUserStatusText = (status: number) => {
return status === 1 ? '正常' : '禁用'
}
export const mapOrderStatus = (status: number | string): Order['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'PENDING' || value === 1) return 'PENDING'
if (value === 'PAID' || value === 2) return 'PAID'
if (value === 'SHIPPED' || value === 3) return 'SHIPPED'
if (value === 'COMPLETED' || value === 4) return 'COMPLETED'
if (value === 'CANCELLED' || value === 5) return 'CANCELLED'
if (value === 'REFUNDING' || value === 6) return 'REFUNDING'
if (value === 'REFUNDED' || value === 7) return 'REFUNDED'
return 'PENDING'
}
export const mapOrderType = (orderType: number | string | undefined, isFlashSale?: boolean): 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING' => {
const value = typeof orderType === 'string' ? orderType : toNumber(orderType)
if (value === 'FLASH_SALE' || value === 2) return 'FLASH_SALE'
if (value === 'GROUP_BUYING' || value === 3) return 'GROUP_BUYING'
if (isFlashSale) return 'FLASH_SALE'
return 'NORMAL'
}
export const mapFlashSaleStatus = (status: number | string): FlashSale['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
if (value === 'ACTIVE' || value === 2) return 'ACTIVE'
if (value === 'ENDED' || value === 3) return 'ENDED'
if (value === 'PAUSED' || value === 4) return 'PAUSED'
return 'UPCOMING'
}
export const mapProductStatus = (status: number | string, stock = 0): Product['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (stock <= 0) return 'SOLD_OUT'
if (value === 'OFF_SALE' || value === 0) return 'OFF_SALE'
return 'ON_SALE'
}
export const normalizeUser = (user: Record<string, any>): User => {
const username = toString(user.username)
return {
id: toNumber(user.id),
username,
email: toString(user.email),
phone: toString(user.phone),
avatar: resolveImageUrl(toString(user.avatar, '')),
role: toString(user.role).toUpperCase() === 'ADMIN' ? 'ADMIN' : username === 'admin' ? 'ADMIN' : 'USER',
status: toNumber(user.status, 1) === 1 ? 'ACTIVE' : 'BANNED',
createdAt: toIsoLikeString(user.createdAt),
updatedAt: toIsoLikeString(user.updatedAt || user.createdAt),
}
}
export const normalizeProduct = (product: Record<string, any>): Product => {
const stock = toNumber(product.stock)
const imageUrl = resolveImageUrl(toString(product.imageUrl, ''))
return {
id: toNumber(product.id),
name: toString(product.name),
description: toString(product.description),
price: toNumber(product.price),
stock,
imageUrl,
images: imageUrl ? [imageUrl] : [DEFAULT_PRODUCT_IMAGE],
category: toString(product.category, '默认分类'),
status: mapProductStatus(product.status, stock),
sales: toNumber(product.sales),
views: toNumber(product.viewCount ?? product.views),
createdAt: toIsoLikeString(product.createdAt),
updatedAt: toIsoLikeString(product.updatedAt || product.createdAt),
}
}
export const normalizeFlashSale = (flashSale: Record<string, any>): FlashSale => {
const flashStock = toNumber(flashSale.flashStock)
const remainingStock = toNumber(flashSale.remainingStock, flashStock)
return {
id: toNumber(flashSale.id),
productId: toNumber(flashSale.productId),
productName: toString(flashSale.productName),
productImageUrl: resolveImageUrl(toString(flashSale.productImageUrl, '')),
originalPrice: toNumber(flashSale.originalPrice),
flashPrice: toNumber(flashSale.flashPrice),
flashStock,
remainingStock,
startTime: toIsoLikeString(flashSale.startTime),
endTime: toIsoLikeString(flashSale.endTime),
status: mapFlashSaleStatus(flashSale.status),
limitPerUser: toNumber(flashSale.limitPerUser, 1),
description: toString(flashSale.description || flashSale.statusDescription),
createdAt: toIsoLikeString(flashSale.createdAt),
updatedAt: toIsoLikeString(flashSale.updatedAt || flashSale.createdAt),
}
}
const buildOrderAddress = (order: Record<string, any>): OrderAddress | undefined => {
const name = toString(order.receiverName)
const phone = toString(order.receiverPhone)
const address = toString(order.receiverAddress)
if (!name && !phone && !address) {
return undefined
}
return {
name,
phone,
province: '',
city: '',
district: '',
address,
}
}
export const normalizeOrder = (order: Record<string, any>): Order => {
const totalAmount = toNumber(order.totalAmount ?? order.totalPrice)
const quantity = toNumber(order.quantity, 1)
const status = mapOrderStatus(order.status)
const createdAt = toIsoLikeString(order.createdAt)
const updatedAt = toIsoLikeString(order.updatedAt || order.createdAt)
const productImage = resolveImageUrl(toString(order.productImageUrl, ''))
const fallbackItem = {
id: toNumber(order.productId || order.id),
productId: toNumber(order.productId),
productName: toString(order.productName, '未知商品'),
productImage,
price: quantity > 0 ? Number((totalAmount / quantity).toFixed(2)) : totalAmount,
quantity,
subtotal: totalAmount,
}
const items = Array.isArray(order.items) && order.items.length > 0
? order.items.map((item: Record<string, any>) => ({
id: toNumber(item.id || item.productId),
productId: toNumber(item.productId),
productName: toString(item.productName, '未知商品'),
productImage: resolveImageUrl(toString(item.productImageUrl || item.productImage, '')),
price: toNumber(item.price),
quantity: toNumber(item.quantity, 1),
subtotal: toNumber(item.subtotal ?? item.price),
}))
: [fallbackItem]
return {
id: toNumber(order.id),
orderNo: toString(order.orderNo, buildOrderNo(order.id)),
userId: toNumber(order.userId),
username: toString(order.username),
totalAmount,
paymentAmount: totalAmount,
paymentMethod: toString(order.paymentMethod) || (status === 'PENDING' ? undefined : 'ONLINE'),
status,
orderType: mapOrderType(order.orderType, order.isFlashSale),
items,
address: buildOrderAddress(order),
remark: toString(order.remark),
createdAt,
updatedAt,
paidAt: order.paidAt ? toIsoLikeString(order.paidAt) : (status === 'PAID' || status === 'SHIPPED' || status === 'COMPLETED' ? updatedAt : undefined),
shippedAt: order.shippedAt ? toIsoLikeString(order.shippedAt) : (status === 'SHIPPED' || status === 'COMPLETED' ? updatedAt : undefined),
completedAt: order.completedAt ? toIsoLikeString(order.completedAt) : (status === 'COMPLETED' ? updatedAt : undefined),
}
}
export const normalizeCartItems = (cart: Record<string, any> | undefined): CartItem[] => {
const items = Array.isArray(cart?.items) ? cart.items : []
return items.map((item: Record<string, any>) => ({
id: toString(item.productId),
productId: toNumber(item.productId),
productName: toString(item.productName),
productImage: resolveImageUrl(toString(item.productImageUrl || item.productImage, '')),
price: toNumber(item.productPrice),
quantity: toNumber(item.quantity, 1),
stock: toNumber(item.stock),
selected: true,
createdAt: new Date().toISOString(),
}))
}
export const normalizePage = <T>(payload: Record<string, any>, mapper: (item: Record<string, any>) => T): PageResponse<T> => {
const content = Array.isArray(payload.content) ? payload.content.map((item: Record<string, any>) => mapper(item)) : []
const size = toNumber(payload.size, content.length || 10)
const pageNumber = toNumber(payload.currentPage ?? payload.number)
const totalElements = toNumber(payload.totalElements, content.length)
const totalPages = toNumber(payload.totalPages, size > 0 ? Math.ceil(totalElements / size) : 1)
return {
content,
totalElements,
totalPages,
size,
number: pageNumber,
first: pageNumber <= 0,
last: totalPages === 0 ? true : pageNumber >= totalPages - 1,
}
}
export const normalizeAdminRecentOrder = (order: Record<string, any>): AdminRecentOrderRow => ({
id: toNumber(order.id),
orderNo: buildOrderNo(order.id),
username: toString(order.username),
productName: toString(order.productName),
quantity: toNumber(order.quantity, 1),
totalAmount: toNumber(order.totalAmount ?? order.totalPrice),
status: mapOrderStatus(order.status),
orderType: mapOrderType(order.orderType, order.isFlashSale),
createdAt: toIsoLikeString(order.createdAt),
isFlashSale: Boolean(order.isFlashSale),
})
export const normalizeAdminHotProduct = (product: Record<string, any>): AdminHotProductRow => ({
id: toNumber(product.id),
name: toString(product.name),
price: toNumber(product.price),
stock: toNumber(product.stock),
sales: toNumber(product.sales),
})
export const normalizeAdminUser = (user: Record<string, any>): AdminUserRow => ({
id: toNumber(user.id),
username: toString(user.username),
email: toString(user.email),
phone: toString(user.phone),
status: toNumber(user.status, 1),
statusText: mapUserStatusText(toNumber(user.status, 1)),
role: toString(user.role).toUpperCase() === 'ADMIN' || toString(user.username) === 'admin' ? 'ADMIN' : 'USER',
isOnline: Boolean(user.isOnline),
createdAt: toIsoLikeString(user.createdAt),
lastLogin: user.lastLogin ? toIsoLikeString(user.lastLogin) : undefined,
})
export const normalizeAdminOrder = (order: Record<string, any>): AdminOrderRow => ({
id: toNumber(order.id),
orderNo: buildOrderNo(order.id),
username: toString(order.username),
productName: toString(order.productName),
productId: toNumber(order.productId),
quantity: toNumber(order.quantity, 1),
totalAmount: toNumber(order.totalAmount),
status: mapOrderStatus(order.status),
orderType: mapOrderType(order.orderType, order.isFlashSale),
createdAt: toIsoLikeString(order.createdAt),
isFlashSale: Boolean(order.isFlashSale),
})
export const normalizeAdminProduct = (product: Record<string, any>): AdminProductRow => ({
id: toNumber(product.id),
name: toString(product.name),
description: toString(product.description),
category: toString(product.category, '默认分类'),
price: toNumber(product.price),
stock: toNumber(product.stock),
status: toNumber(product.status, 1),
imageUrl: normalizeStorageImageUrl(toString(product.imageUrl, '')),
createdAt: toIsoLikeString(product.createdAt),
updatedAt: product.updatedAt ? toIsoLikeString(product.updatedAt) : undefined,
totalSales: toNumber(product.totalSales),
totalRevenue: toNumber(product.totalRevenue),
viewCount: toNumber(product.viewCount),
rating: toNumber(product.rating),
})
export const mapGroupBuyingStatus = (status: number | string): GroupBuying['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'DRAFT' || value === 0) return 'DRAFT'
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
if (value === 'ACTIVE' || value === 2) return 'ACTIVE'
if (value === 'ENDED' || value === 3) return 'ENDED'
return 'DRAFT'
}
export const mapGroupStatus = (status: number | string): GroupBuyingGroup['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'FORMING' || value === 1) return 'FORMING'
if (value === 'SUCCESS' || value === 2) return 'SUCCESS'
if (value === 'FAILED' || value === 3) return 'FAILED'
return 'FORMING'
}
export const normalizeGroupBuying = (gb: Record<string, any>): GroupBuying => ({
id: toNumber(gb.id),
productId: toNumber(gb.productId),
productName: toString(gb.productName),
productImageUrl: resolveImageUrl(toString(gb.productImageUrl, '')),
productPrice: toNumber(gb.productPrice),
groupPrice: toNumber(gb.groupPrice),
requiredMembers: toNumber(gb.requiredMembers, 2),
durationMinutes: toNumber(gb.durationMinutes, 1440),
totalStock: toNumber(gb.totalStock),
remainingStock: toNumber(gb.remainingStock),
maxPerUser: toNumber(gb.maxPerUser, 1),
status: mapGroupBuyingStatus(gb.status),
statusDescription: toString(gb.statusDescription),
startTime: toIsoLikeString(gb.startTime),
endTime: toIsoLikeString(gb.endTime),
createdAt: toIsoLikeString(gb.createdAt),
updatedAt: toIsoLikeString(gb.updatedAt || gb.createdAt),
activeGroupCount: toNumber(gb.activeGroupCount),
discount: toNumber(gb.discount),
})
export const mapReturnStatus = (status: number | string): OrderReturn['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'PENDING' || value === 1) return 'PENDING'
if (value === 'APPROVED' || value === 2) return 'APPROVED'
if (value === 'RETURNING' || value === 3) return 'RETURNING'
if (value === 'COMPLETED' || value === 4) return 'COMPLETED'
if (value === 'REJECTED' || value === 5) return 'REJECTED'
if (value === 'CANCELLED' || value === 6) return 'CANCELLED'
return 'PENDING'
}
export const normalizeOrderReturn = (ret: Record<string, any>): OrderReturn => ({
id: toNumber(ret.id),
returnNo: toString(ret.returnNo),
orderId: toNumber(ret.orderId),
orderNo: toString(ret.orderNo),
userId: toNumber(ret.userId),
username: toString(ret.username),
refundAmount: toNumber(ret.refundAmount),
reason: toString(ret.reason),
description: toString(ret.description),
images: toString(ret.images),
status: mapReturnStatus(ret.status),
statusText: toString(ret.statusText),
rejectReason: toString(ret.rejectReason),
adminRemark: toString(ret.adminRemark),
returnTracking: toString(ret.returnTracking),
productName: toString(ret.productName),
productImage: resolveImageUrl(toString(ret.productImage, '')),
reviewedAt: ret.reviewedAt ? toIsoLikeString(ret.reviewedAt) : undefined,
shippedAt: ret.shippedAt ? toIsoLikeString(ret.shippedAt) : undefined,
completedAt: ret.completedAt ? toIsoLikeString(ret.completedAt) : undefined,
cancelledAt: ret.cancelledAt ? toIsoLikeString(ret.cancelledAt) : undefined,
createdAt: toIsoLikeString(ret.createdAt),
updatedAt: toIsoLikeString(ret.updatedAt || ret.createdAt),
})
export const normalizeGroupBuyingGroup = (group: Record<string, any>): GroupBuyingGroup => ({
id: toNumber(group.id),
groupNo: toString(group.groupNo),
groupBuyingId: toNumber(group.groupBuyingId),
leaderUserId: toNumber(group.leaderUserId),
leaderUsername: toString(group.leaderUsername),
requiredMembers: toNumber(group.requiredMembers, 2),
currentMembers: toNumber(group.currentMembers, 1),
status: mapGroupStatus(group.status),
statusDescription: toString(group.statusDescription),
expireTime: toIsoLikeString(group.expireTime),
createdAt: toIsoLikeString(group.createdAt),
completedAt: group.completedAt ? toIsoLikeString(group.completedAt) : undefined,
members: Array.isArray(group.members)
? group.members.map((m: Record<string, any>) => ({
id: toNumber(m.id),
userId: toNumber(m.userId),
username: toString(m.username),
avatar: resolveImageUrl(toString(m.avatar, '')),
orderId: m.orderId ? toNumber(m.orderId) : undefined,
status: toNumber(m.status),
joinedAt: toIsoLikeString(m.joinedAt),
}))
: [],
groupBuying: group.groupBuying ? normalizeGroupBuying(group.groupBuying) : undefined,
})

View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f7f7f6',
100: '#efefed',
200: '#dfdfdc',
300: '#c6c6c2',
400: '#9f9f99',
500: '#7b7b74',
600: '#5e5e58',
700: '#44443f',
800: '#2b2b27',
900: '#171715',
},
},
animation: {
'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"vite/client",
"element-plus/global",
"node"
]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -0,0 +1,53 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (proxyPath) => proxyPath.replace(/^\/api/, '/api'),
},
'/images': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
element: ['element-plus', '@element-plus/icons-vue'],
utils: ['axios', 'dayjs', '@vueuse/core'],
charts: ['echarts', 'vue-echarts'],
},
},
},
},
})