feat: 删除JSP视图层,完善评价和通知系统,新增拼团模块
- 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA - 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API - 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API - 新增拼团模块:GroupBuying 全套后端和前端页面 - 修复 review check API 参数双重包装导致请求格式错误 - 修复通知 API 路径缺少 /api 前缀和响应格式处理 - MessageListenerService 集成 NotificationService 创建持久化通知 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,11 +66,11 @@ mysql -u root -p -e "CREATE DATABASE flash_sale_db CHARACTER SET utf8mb4 COLLATE
|
|||||||
# 导入表结构(JPA自动创建,可选)
|
# 导入表结构(JPA自动创建,可选)
|
||||||
mysql -u root -p flash_sale_db < src/main/resources/sql/schema.sql
|
mysql -u root -p flash_sale_db < src/main/resources/sql/schema.sql
|
||||||
|
|
||||||
# 导入测试数据
|
|
||||||
mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql
|
|
||||||
|
|
||||||
# 导入演示用户
|
# 导入演示用户
|
||||||
mysql -u root -p flash_sale_db < src/main/resources/sql/demo-users.sql
|
mysql -u root -p flash_sale_db < src/main/resources/sql/demo-users.sql
|
||||||
|
|
||||||
|
# 导入测试数据
|
||||||
|
mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Redis架构设计
|
## Redis架构设计
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -89,11 +89,9 @@ FlashSaleSystem/
|
|||||||
│ │ ├── rate_limit.lua # 限流脚本
|
│ │ ├── rate_limit.lua # 限流脚本
|
||||||
│ │ └── unlock.lua # 解锁脚本
|
│ │ └── unlock.lua # 解锁脚本
|
||||||
│ ├── sql/ # SQL脚本
|
│ ├── sql/ # SQL脚本
|
||||||
│ │ ├── demo-users.sql # 演示用户数据
|
│ │ ├── demo-users.sql # 演示账号
|
||||||
│ │ ├── fix-demo-users.sql # 修复用户数据
|
│ │ ├── schema.sql # 数据库结构
|
||||||
│ │ ├── schema.sql # 数据库架构
|
│ │ └── test-data.sql # 测试业务数据
|
||||||
│ │ ├── test-data.sql # 测试数据
|
|
||||||
│ │ └── update-passwords.sql # 更新密码
|
|
||||||
│ └── static/images/ # 静态图片资源
|
│ └── static/images/ # 静态图片资源
|
||||||
└── src/main/webapp/WEB-INF/views/ # JSP页面
|
└── src/main/webapp/WEB-INF/views/ # JSP页面
|
||||||
├── admin/ # 管理员页面
|
├── admin/ # 管理员页面
|
||||||
@@ -228,13 +226,14 @@ cd FlashSaleSystem
|
|||||||
```bash
|
```bash
|
||||||
# 创建数据库
|
# 创建数据库
|
||||||
mysql -u root -p
|
mysql -u root -p
|
||||||
CREATE DATABASE flashsale_db;
|
CREATE DATABASE flash_sale_db;
|
||||||
|
|
||||||
# 导入数据库架构
|
# 导入数据库架构
|
||||||
mysql -u root -p flashsale_db < src/main/resources/sql/schema.sql
|
mysql -u root -p flash_sale_db < src/main/resources/sql/schema.sql
|
||||||
|
|
||||||
# 导入测试数据
|
# 导入测试数据
|
||||||
mysql -u root -p flashsale_db < src/main/resources/sql/test-data.sql
|
mysql -u root -p flash_sale_db < src/main/resources/sql/demo-users.sql
|
||||||
|
mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **配置 Redis 集群**
|
3. **配置 Redis 集群**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
VITE_APP_TITLE=秒杀系统
|
VITE_APP_TITLE=秒杀系统
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
VITE_API_BASE_URL=
|
||||||
VITE_WS_URL=ws://localhost:8080/ws
|
VITE_WS_URL=ws://localhost:8080/ws
|
||||||
VITE_UPLOAD_URL=http://localhost:8080/upload
|
VITE_UPLOAD_URL=http://localhost:8080/upload
|
||||||
VITE_TIMEOUT=10000
|
VITE_TIMEOUT=10000
|
||||||
|
|||||||
64
flash-sale-frontend/package-lock.json
generated
64
flash-sale-frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@types/node": "^20.11.5",
|
"@types/node": "^20.11.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
@@ -1142,6 +1143,22 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"name": "@sxzz/popperjs-es",
|
"name": "@sxzz/popperjs-es",
|
||||||
"version": "2.11.7",
|
"version": "2.11.7",
|
||||||
@@ -4221,6 +4238,53 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
"@vue/eslint-config-prettier": "^9.0.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0"
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
|
"@playwright/test": "^1.52.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ onMounted(() => {
|
|||||||
<style>
|
<style>
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const flashSaleSortField = (sort?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const flashsaleApi = {
|
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>>> {
|
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<FlashSale>>> {
|
||||||
return request.post<ApiResponse<Record<string, any>>>('/api/flashsale/list', {
|
return request.post<ApiResponse<Record<string, any>>>('/api/flashsale/list', {
|
||||||
|
|||||||
108
flash-sale-frontend/src/api/modules/groupbuying.ts
Normal file
108
flash-sale-frontend/src/api/modules/groupbuying.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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')
|
||||||
|
},
|
||||||
|
}
|
||||||
45
flash-sale-frontend/src/api/modules/notification.ts
Normal file
45
flash-sale-frontend/src/api/modules/notification.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ export interface ReviewItem {
|
|||||||
userId: number
|
userId: number
|
||||||
orderId: number
|
orderId: number
|
||||||
username: string
|
username: string
|
||||||
|
productName?: string
|
||||||
|
productImage?: string
|
||||||
rating: number
|
rating: number
|
||||||
content: string
|
content: string
|
||||||
|
adminReply?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
@@ -19,6 +22,11 @@ export interface ReviewSummary {
|
|||||||
reviews: ReviewItem[]
|
reviews: ReviewItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewCheckResult {
|
||||||
|
reviewed: boolean
|
||||||
|
review?: ReviewItem
|
||||||
|
}
|
||||||
|
|
||||||
export const reviewApi = {
|
export const reviewApi = {
|
||||||
getProductReviews(productId: number): Promise<ApiResponse<ReviewSummary>> {
|
getProductReviews(productId: number): Promise<ApiResponse<ReviewSummary>> {
|
||||||
return request.get(`/api/review/product/${productId}`)
|
return request.get(`/api/review/product/${productId}`)
|
||||||
@@ -27,4 +35,16 @@ export const reviewApi = {
|
|||||||
create(data: { orderId: number; productId: number; rating: number; content: string }): Promise<ApiResponse<ReviewItem>> {
|
create(data: { orderId: number; productId: number; rating: number; content: string }): Promise<ApiResponse<ReviewItem>> {
|
||||||
return request.post('/api/review', data)
|
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}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import request from './request'
|
|
||||||
import type { Product, ProductParams } from '@/types/product'
|
|
||||||
|
|
||||||
export const productApi = {
|
|
||||||
// 获取商品列表
|
|
||||||
getList(params?: ProductParams) {
|
|
||||||
return request.get<any, { list: Product[], total: number }>('/api/products', { params })
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取商品详情
|
|
||||||
getDetail(id: number) {
|
|
||||||
return request.get<any, Product>(`/api/products/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取热门商品
|
|
||||||
getHot(limit: number = 8) {
|
|
||||||
return request.get<any, Product[]>('/api/products/hot', {
|
|
||||||
params: { limit }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取推荐商品
|
|
||||||
getRecommended(limit: number = 8) {
|
|
||||||
return request.get<any, Product[]>('/api/products/recommended', {
|
|
||||||
params: { limit }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// 搜索商品
|
|
||||||
search(keyword: string) {
|
|
||||||
return request.get<any, Product[]>('/api/products/search', {
|
|
||||||
params: { keyword }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// 按分类获取商品
|
|
||||||
getByCategory(categoryId: number) {
|
|
||||||
return request.get<any, Product[]>('/api/products/category', {
|
|
||||||
params: { categoryId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default productApi
|
|
||||||
@@ -10,7 +10,8 @@ import router from '@/router'
|
|||||||
|
|
||||||
// 创建axios实例
|
// 创建axios实例
|
||||||
const service: AxiosInstance = axios.create({
|
const service: AxiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||||
|
withCredentials: true,
|
||||||
timeout: Number(import.meta.env.VITE_TIMEOUT) || 10000,
|
timeout: Number(import.meta.env.VITE_TIMEOUT) || 10000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="countdown-timer">
|
<div class="countdown-timer">
|
||||||
<template v-if="timeLeft > 0">
|
<template v-if="timeLeft > 0">
|
||||||
<el-icon class="text-red-500 mr-1"><Clock /></el-icon>
|
<el-icon class="countdown-icon mr-1"><Clock /></el-icon>
|
||||||
<span class="time-block">{{ hours.toString().padStart(2, '0') }}</span>
|
<span class="time-block">{{ hours.toString().padStart(2, '0') }}</span>
|
||||||
<span class="separator">:</span>
|
<span class="separator">:</span>
|
||||||
<span class="time-block">{{ minutes.toString().padStart(2, '0') }}</span>
|
<span class="time-block">{{ minutes.toString().padStart(2, '0') }}</span>
|
||||||
@@ -60,12 +60,20 @@ onUnmounted(() => {
|
|||||||
.countdown-timer {
|
.countdown-timer {
|
||||||
@apply flex items-center justify-center text-lg font-mono;
|
@apply flex items-center justify-center text-lg font-mono;
|
||||||
|
|
||||||
|
.countdown-icon {
|
||||||
|
color: #5e5e58;
|
||||||
|
}
|
||||||
|
|
||||||
.time-block {
|
.time-block {
|
||||||
@apply px-2 py-1 bg-red-50 text-red-600 rounded;
|
@apply px-2 py-1 rounded;
|
||||||
|
background: #fff;
|
||||||
|
color: #171715;
|
||||||
|
border: 1px solid #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
@apply mx-1 text-red-500 font-bold;
|
@apply mx-1 font-bold;
|
||||||
|
color: #5e5e58;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
|
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
|
||||||
<div class="flex items-end mb-3">
|
<div class="flex items-end mb-3">
|
||||||
<span class="text-2xl font-bold text-red-500">¥{{ data.flashPrice }}</span>
|
<span class="flash-price">¥{{ data.flashPrice }}</span>
|
||||||
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.originalPrice }}</span>
|
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.originalPrice }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<span v-else-if="data.status === 'UPCOMING'" class="text-sm text-gray-500">即将开始</span>
|
<span v-else-if="data.status === 'UPCOMING'" class="text-sm text-gray-500">即将开始</span>
|
||||||
<span v-else class="text-sm text-gray-400">已结束</span>
|
<span v-else class="text-sm text-gray-400">已结束</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="danger" class="w-full" :disabled="!canParticipate" :loading="loading" @click="handleParticipate">
|
<el-button type="primary" class="w-full" :disabled="!canParticipate" :loading="loading" @click="handleParticipate">
|
||||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
<el-icon class="mr-1"><Lightning /></el-icon>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -76,7 +76,7 @@ const statusText = computed(() => {
|
|||||||
|
|
||||||
const discountPercent = computed(() => Math.round((1 - props.data.flashPrice / props.data.originalPrice) * 100))
|
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 stockPercent = computed(() => props.data.flashStock === 0 ? 0 : Math.round(props.data.remainingStock / props.data.flashStock * 100))
|
||||||
const progressColor = computed(() => stockPercent.value > 50 ? '#67c23a' : stockPercent.value > 20 ? '#e6a23c' : '#f56c6c')
|
const progressColor = computed(() => (stockPercent.value > 50 ? '#171715' : stockPercent.value > 20 ? '#5e5e58' : '#9f9f99'))
|
||||||
const endTime = computed(() => new Date(props.data.endTime).getTime())
|
const endTime = computed(() => new Date(props.data.endTime).getTime())
|
||||||
const canParticipate = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
|
const canParticipate = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
|
||||||
const buttonText = computed(() => {
|
const buttonText = computed(() => {
|
||||||
@@ -96,7 +96,8 @@ const handleParticipate = async () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.flash-sale-card {
|
.flash-sale-card {
|
||||||
@apply bg-white rounded-lg overflow-hidden;
|
@apply bg-white rounded-2xl overflow-hidden;
|
||||||
|
background: #fffaf2;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -104,7 +105,15 @@ const handleParticipate = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flash-price {
|
||||||
|
@apply text-2xl font-bold;
|
||||||
|
color: #171715;
|
||||||
|
}
|
||||||
|
|
||||||
.discount-badge {
|
.discount-badge {
|
||||||
@apply px-2 py-1 bg-orange-500 text-white text-xs font-bold rounded;
|
@apply px-2 py-1 text-xs font-bold rounded;
|
||||||
|
background: #fffaf2;
|
||||||
|
color: #171715;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
118
flash-sale-frontend/src/components/business/GroupBuyingCard.vue
Normal file
118
flash-sale-frontend/src/components/business/GroupBuyingCard.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-buying-card card-shadow" @click="$router.push(`/groupbuying/${data.id}`)">
|
||||||
|
<div class="relative cursor-pointer">
|
||||||
|
<SafeImage
|
||||||
|
:src="data.productImageUrl"
|
||||||
|
:alt="data.productName"
|
||||||
|
wrapper-class="w-full h-48"
|
||||||
|
img-class="w-full h-48 object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 :percentage="stockPercent" :stroke-width="6" :show-text="false" :color="progressColor" />
|
||||||
|
</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 type="primary" class="w-full" :disabled="!canJoin" @click.stop="handleJoin">
|
||||||
|
<el-icon class="mr-1"><Connection /></el-icon>
|
||||||
|
{{ buttonText }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 scoped lang="scss">
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-member-list">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<div v-for="member in members" :key="member.userId" class="member-avatar" :title="member.username">
|
||||||
|
<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" size="small" type="warning" class="leader-tag">团长</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 setup lang="ts">
|
||||||
|
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 scoped lang="scss">
|
||||||
|
.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>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
{{ data.description || '暂无描述' }}
|
{{ data.description || '暂无描述' }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex justify-between items-center mb-3">
|
||||||
<span class="text-xl font-bold text-primary-500">¥{{ data.price }}</span>
|
<span class="price">¥{{ data.price }}</span>
|
||||||
<span class="text-sm text-gray-400">库存: {{ data.stock }}</span>
|
<span class="text-sm text-gray-400">库存: {{ data.stock }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -59,7 +59,8 @@ const handleViewDetail = () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.product-card {
|
.product-card {
|
||||||
@apply bg-white rounded-lg overflow-hidden;
|
@apply bg-white rounded-2xl overflow-hidden;
|
||||||
|
background: #fffaf2;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -67,6 +68,11 @@ const handleViewDetail = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
@apply text-xl font-bold;
|
||||||
|
color: #171715;
|
||||||
|
}
|
||||||
|
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
|||||||
181
flash-sale-frontend/src/components/business/ReviewDialog.vue
Normal file
181
flash-sale-frontend/src/components/business/ReviewDialog.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<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 :src="item.productImage" :alt="item.productName" wrapper-class="w-16 h-16 rounded" img-class="w-16 h-16 object-cover 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" show-text :texts="['很差', '较差', '一般', '满意', '非常满意']" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-600 mb-1">评价内容</label>
|
||||||
|
<el-input
|
||||||
|
v-model="item.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="分享一下你的使用感受吧"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</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 :src="item.productImage" :alt="item.productName" wrapper-class="w-16 h-16 rounded" img-class="w-16 h-16 object-cover rounded" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||||
|
<el-tag type="success" size="small">已评价</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"
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
提交评价
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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) 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()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -15,17 +15,17 @@
|
|||||||
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
|
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<router-link to="/" class="text-gray-600 hover:text-primary-500">
|
<router-link to="/" class="footer-link">
|
||||||
首页
|
首页
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link to="/flashsales" class="text-gray-600 hover:text-primary-500">
|
<router-link to="/flashsales" class="footer-link">
|
||||||
秒杀活动
|
秒杀活动
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link to="/products" class="text-gray-600 hover:text-primary-500">
|
<router-link to="/products" class="footer-link">
|
||||||
商品列表
|
商品列表
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
@@ -73,16 +73,25 @@
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.app-footer {
|
.app-footer {
|
||||||
background: white;
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border-top: 1px solid #e5e5e5;
|
border-top: 1px solid #d8cebf;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-tag {
|
.footer-link {
|
||||||
padding: 2px 8px;
|
color: #5e5e58;
|
||||||
background-color: #f0f0f0;
|
|
||||||
border-radius: 4px;
|
&:hover {
|
||||||
font-size: 12px;
|
color: #171715;
|
||||||
color: #666;
|
}
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.tech-tag {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background-color: #fffaf2;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5c5346;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
<nav class="flex items-center justify-between h-16">
|
<nav class="flex items-center justify-between h-16">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<router-link to="/" class="flex items-center space-x-2">
|
<router-link to="/" class="brand-link">
|
||||||
<el-icon :size="24" class="text-red-500">
|
<el-icon :size="24" class="brand-icon">
|
||||||
<Lightning />
|
<Lightning />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span class="text-xl font-bold">秒杀系统</span>
|
<span class="brand-title">秒杀系统</span>
|
||||||
<span class="ml-2 px-2 py-1 text-xs bg-gradient-to-r from-red-500 to-pink-500 text-white rounded-full">
|
<span class="brand-tag">
|
||||||
FLASH SALE
|
FLASH SALE
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -25,9 +25,28 @@
|
|||||||
<el-icon><Lightning /></el-icon>
|
<el-icon><Lightning /></el-icon>
|
||||||
秒杀活动
|
秒杀活动
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link to="/products" class="nav-link">
|
<el-dropdown trigger="hover" @command="handleCategoryCommand">
|
||||||
<el-icon><ShoppingBag /></el-icon>
|
<router-link to="/products" class="nav-link">
|
||||||
商品列表
|
<el-icon><ShoppingBag /></el-icon>
|
||||||
|
商品列表
|
||||||
|
<el-icon class="ml-1" :size="12"><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 to="/groupbuying" class="nav-link">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
拼团
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,7 +58,7 @@
|
|||||||
<NotificationCenter v-if="userStore.isLoggedIn" />
|
<NotificationCenter v-if="userStore.isLoggedIn" />
|
||||||
|
|
||||||
<!-- 购物车 -->
|
<!-- 购物车 -->
|
||||||
<router-link to="/cart" class="relative">
|
<router-link to="/cart" class="cart-link relative">
|
||||||
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
|
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
|
||||||
<el-icon :size="20"><ShoppingCart /></el-icon>
|
<el-icon :size="20"><ShoppingCart /></el-icon>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
@@ -48,7 +67,7 @@
|
|||||||
<!-- 用户菜单 -->
|
<!-- 用户菜单 -->
|
||||||
<template v-if="userStore.isLoggedIn">
|
<template v-if="userStore.isLoggedIn">
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<div class="flex items-center space-x-2 cursor-pointer">
|
<div class="user-trigger flex items-center space-x-2 cursor-pointer">
|
||||||
<el-avatar :size="32" :src="userStore.user?.avatar">
|
<el-avatar :size="32" :src="userStore.user?.avatar">
|
||||||
{{ userStore.username[0] }}
|
{{ userStore.username[0] }}
|
||||||
</el-avatar>
|
</el-avatar>
|
||||||
@@ -68,6 +87,14 @@
|
|||||||
<el-icon><Star /></el-icon>
|
<el-icon><Star /></el-icon>
|
||||||
我的收藏
|
我的收藏
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="router.push('/reviews')">
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
我的评价
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="router.push('/notifications')">
|
||||||
|
<el-icon><Bell /></el-icon>
|
||||||
|
消息通知
|
||||||
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
|
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon><Setting /></el-icon>
|
||||||
管理后台
|
管理后台
|
||||||
@@ -97,6 +124,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import { useCartStore } from '@/stores/cart'
|
||||||
|
import { productApi } from '@/api/modules/product'
|
||||||
import NotificationCenter from './NotificationCenter.vue'
|
import NotificationCenter from './NotificationCenter.vue'
|
||||||
import SearchComponent from './SearchComponent.vue'
|
import SearchComponent from './SearchComponent.vue'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
@@ -106,6 +134,28 @@ const userStore = useUserStore()
|
|||||||
const cartStore = useCartStore()
|
const cartStore = useCartStore()
|
||||||
|
|
||||||
const cartCount = ref(0)
|
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 handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -126,6 +176,7 @@ const updateCartCount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadCategories()
|
||||||
updateCartCount()
|
updateCartCount()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -137,32 +188,103 @@ onMounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: white;
|
background: rgba(255, 250, 242, 0.92);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
backdrop-filter: none;
|
||||||
|
border-bottom: 1px solid #d8cebf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #171715;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px 12px;
|
padding: 8px 2px;
|
||||||
color: #333;
|
color: #5e5e58;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.3s;
|
transition: color 0.25s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--primary-color);
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.router-link-active {
|
&.router-link-active {
|
||||||
color: var(--primary-color);
|
color: #171715;
|
||||||
font-weight: 500;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-link,
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-badge {
|
.cart-badge {
|
||||||
:deep(.el-badge__content) {
|
:deep(.el-badge__content) {
|
||||||
background-color: var(--primary-color);
|
background-color: #fffaf2;
|
||||||
|
color: #171715;
|
||||||
|
border: 1px solid #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
<div class="message">{{ item.message }}</div>
|
<div class="message">{{ item.message }}</div>
|
||||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!item.read"
|
v-if="!item.read"
|
||||||
@@ -71,13 +71,13 @@
|
|||||||
:class="{ unread: !item.read }"
|
:class="{ unread: !item.read }"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" class="text-red-500">
|
<el-icon :size="16" class="notification-icon">
|
||||||
<Lightning />
|
<Lightning />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
<div class="message">{{ item.message }}</div>
|
<div class="message">{{ item.message }}</div>
|
||||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,13 +94,13 @@
|
|||||||
:class="{ unread: !item.read }"
|
:class="{ unread: !item.read }"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" class="text-blue-500">
|
<el-icon :size="16" class="notification-icon">
|
||||||
<List />
|
<List />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
<div class="message">{{ item.message }}</div>
|
<div class="message">{{ item.message }}</div>
|
||||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
<div class="message">{{ item.message }}</div>
|
<div class="message">{{ item.message }}</div>
|
||||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +146,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
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 dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import 'dayjs/locale/zh-cn'
|
import 'dayjs/locale/zh-cn'
|
||||||
@@ -155,75 +158,47 @@ dayjs.extend(relativeTime)
|
|||||||
dayjs.locale('zh-cn')
|
dayjs.locale('zh-cn')
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { subscribe, unsubscribe } = useWebSocket()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string
|
|
||||||
type: 'flashsale' | 'order' | 'system'
|
|
||||||
title: string
|
|
||||||
message: string
|
|
||||||
timestamp: number
|
|
||||||
read: boolean
|
|
||||||
link?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
const notifications = ref<Notification[]>([
|
const notifications = ref<NotificationItem[]>([])
|
||||||
{
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
id: '1',
|
|
||||||
type: 'flashsale',
|
|
||||||
title: '秒杀即将开始',
|
|
||||||
message: 'iPhone 15 Pro 秒杀活动将在10分钟后开始',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 5,
|
|
||||||
read: false,
|
|
||||||
link: '/flashsale/1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'order',
|
|
||||||
title: '订单已发货',
|
|
||||||
message: '您的订单 ORD2024001 已发货,请注意查收',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 30,
|
|
||||||
read: false,
|
|
||||||
link: '/order/1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: 'system',
|
|
||||||
title: '系统维护通知',
|
|
||||||
message: '系统将于今晚22:00-23:00进行维护升级',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 60,
|
|
||||||
read: true
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const unreadCount = computed(() =>
|
const unreadCount = computed(() =>
|
||||||
notifications.value.filter(n => !n.read).length
|
notifications.value.filter(n => !n.read).length
|
||||||
)
|
)
|
||||||
|
|
||||||
const allNotifications = computed(() =>
|
const allNotifications = computed(() => notifications.value)
|
||||||
notifications.value.slice().sort((a, b) => b.timestamp - a.timestamp)
|
|
||||||
)
|
|
||||||
|
|
||||||
const flashsaleNotifications = computed(() =>
|
const flashsaleNotifications = computed(() =>
|
||||||
notifications.value.filter(n => n.type === 'flashsale')
|
notifications.value.filter(n => n.type === 'flashsale')
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const orderNotifications = computed(() =>
|
const orderNotifications = computed(() =>
|
||||||
notifications.value.filter(n => n.type === 'order')
|
notifications.value.filter(n => n.type === 'order')
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemNotifications = computed(() =>
|
const systemNotifications = computed(() =>
|
||||||
notifications.value.filter(n => n.type === 'system')
|
notifications.value.filter(n => n.type === 'system')
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 从后端加载通知
|
||||||
|
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) => {
|
const formatTime = (timestamp: number | string) => {
|
||||||
return dayjs(timestamp).fromNow()
|
return dayjs(timestamp).fromNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,34 +215,49 @@ const getIcon = (type: string) => {
|
|||||||
// 获取图标类名
|
// 获取图标类名
|
||||||
const getIconClass = (type: string) => {
|
const getIconClass = (type: string) => {
|
||||||
const classes: Record<string, string> = {
|
const classes: Record<string, string> = {
|
||||||
'flashsale': 'text-red-500',
|
'flashsale': 'notification-icon',
|
||||||
'order': 'text-blue-500',
|
'order': 'notification-icon',
|
||||||
'system': 'text-gray-500'
|
'system': 'notification-icon muted'
|
||||||
}
|
}
|
||||||
return classes[type] || 'text-gray-500'
|
return classes[type] || 'text-gray-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记已读
|
// 标记已读
|
||||||
const markAsRead = (id: string) => {
|
const markAsRead = async (id: number | string) => {
|
||||||
const notification = notifications.value.find(n => n.id === id)
|
const notification = notifications.value.find(n => String(n.id) === String(id))
|
||||||
if (notification) {
|
if (notification && !notification.read) {
|
||||||
notification.read = true
|
try {
|
||||||
|
await notificationApi.markAsRead(Number(id))
|
||||||
|
notification.read = true
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全部标记已读
|
// 全部标记已读
|
||||||
const markAllAsRead = () => {
|
const markAllAsRead = async () => {
|
||||||
notifications.value.forEach(n => n.read = true)
|
try {
|
||||||
|
await notificationApi.markAllAsRead()
|
||||||
|
notifications.value.forEach(n => n.read = true)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空消息
|
// 清空消息
|
||||||
const clearAll = () => {
|
const clearAll = async () => {
|
||||||
notifications.value = []
|
try {
|
||||||
visible.value = false
|
await notificationApi.clearAll()
|
||||||
|
notifications.value = []
|
||||||
|
visible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理点击
|
// 处理点击
|
||||||
const handleClick = (item: Notification) => {
|
const handleClick = (item: NotificationItem) => {
|
||||||
markAsRead(item.id)
|
markAsRead(item.id)
|
||||||
if (item.link) {
|
if (item.link) {
|
||||||
router.push(item.link)
|
router.push(item.link)
|
||||||
@@ -275,56 +265,17 @@ const handleClick = (item: Notification) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket消息处理
|
|
||||||
const handleFlashSaleMessage = (data: any) => {
|
|
||||||
notifications.value.unshift({
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: 'flashsale',
|
|
||||||
title: '秒杀提醒',
|
|
||||||
message: data.message,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false,
|
|
||||||
link: data.link
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOrderMessage = (data: any) => {
|
|
||||||
notifications.value.unshift({
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: 'order',
|
|
||||||
title: '订单更新',
|
|
||||||
message: data.message,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false,
|
|
||||||
link: data.link
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSystemMessage = (data: any) => {
|
|
||||||
notifications.value.unshift({
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: 'system',
|
|
||||||
title: '系统通知',
|
|
||||||
message: data.content,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 订阅WebSocket消息
|
fetchNotifications()
|
||||||
subscribe('FLASH_SALE_START', handleFlashSaleMessage)
|
// 每60秒轮询一次
|
||||||
subscribe('FLASH_SALE_END', handleFlashSaleMessage)
|
pollTimer = setInterval(fetchNotifications, 60000)
|
||||||
subscribe('ORDER_STATUS', handleOrderMessage)
|
|
||||||
subscribe('SYSTEM_NOTICE', handleSystemMessage)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// 取消订阅
|
if (pollTimer) {
|
||||||
unsubscribe('FLASH_SALE_START', handleFlashSaleMessage)
|
clearInterval(pollTimer)
|
||||||
unsubscribe('FLASH_SALE_END', handleFlashSaleMessage)
|
pollTimer = null
|
||||||
unsubscribe('ORDER_STATUS', handleOrderMessage)
|
}
|
||||||
unsubscribe('SYSTEM_NOTICE', handleSystemMessage)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -334,13 +285,27 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fffaf2;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--el-color-primary);
|
color: #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
color: #44443f;
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
color: #7b7b74;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.notification-content {
|
.notification-content {
|
||||||
.notification-header {
|
.notification-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -385,14 +350,14 @@ onUnmounted(() => {
|
|||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f5f7fa;
|
background-color: #f7f7f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unread {
|
&.unread {
|
||||||
background-color: #f0f9ff;
|
background-color: #f7f7f6;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,14 +394,14 @@ onUnmounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.view-all {
|
.view-all {
|
||||||
color: var(--el-color-primary);
|
color: #44443f;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.8;
|
color: #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const handleClick = () => {
|
|||||||
.safe-image {
|
.safe-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f8fafc;
|
background: #f4ede4;
|
||||||
|
|
||||||
&.is-clickable {
|
&.is-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -85,13 +85,13 @@ const handleClick = () => {
|
|||||||
&__placeholder {
|
&__placeholder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
background: #f4ede4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__shimmer {
|
&__shimmer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 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;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
<el-collapse v-model="activeCollapse">
|
<el-collapse v-model="activeCollapse">
|
||||||
<el-collapse-item name="advanced">
|
<el-collapse-item name="advanced">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="text-sm text-blue-500">
|
<span class="search-advanced-title">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon><Setting /></el-icon>
|
||||||
高级搜索
|
高级搜索
|
||||||
</span>
|
</span>
|
||||||
@@ -250,7 +250,7 @@ const highlightKeyword = (text: string) => {
|
|||||||
if (!searchQuery.value) return text
|
if (!searchQuery.value) return text
|
||||||
|
|
||||||
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
||||||
return text.replace(regex, '<span class="text-red-500 font-bold">$1</span>')
|
return text.replace(regex, '<span class="search-highlight">$1</span>')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取搜索建议
|
// 获取搜索建议
|
||||||
@@ -365,6 +365,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-advanced-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #44443f;
|
||||||
|
}
|
||||||
|
|
||||||
.search-panel {
|
.search-panel {
|
||||||
.search-section {
|
.search-section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -391,7 +399,7 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--el-color-primary-light-9);
|
background-color: #efefed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +416,7 @@ onMounted(async () => {
|
|||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f5f7fa;
|
background-color: #f7f7f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -428,12 +436,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.type {
|
.type {
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
background-color: #f0f0f0;
|
background-color: #efefed;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price {
|
.price {
|
||||||
color: #f56c6c;
|
color: #2b2b27;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +452,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.advanced-search {
|
.advanced-search {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
border-top: 1px solid #e4e7ed;
|
border-top: 1px solid #d8cebf;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
|
||||||
.advanced-form {
|
.advanced-form {
|
||||||
@@ -452,4 +460,4 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,12 @@
|
|||||||
<el-icon><Lightning /></el-icon>
|
<el-icon><Lightning /></el-icon>
|
||||||
<template #title>秒杀管理</template>
|
<template #title>秒杀管理</template>
|
||||||
</el-menu-item>
|
</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-menu-item index="/admin/orders">
|
||||||
<el-icon><List /></el-icon>
|
<el-icon><List /></el-icon>
|
||||||
<template #title>订单管理</template>
|
<template #title>订单管理</template>
|
||||||
@@ -153,6 +158,7 @@ const currentPageTitle = computed(() => {
|
|||||||
'/admin': '',
|
'/admin': '',
|
||||||
'/admin/products': '商品管理',
|
'/admin/products': '商品管理',
|
||||||
'/admin/flashsales': '秒杀管理',
|
'/admin/flashsales': '秒杀管理',
|
||||||
|
'/admin/groupbuying': '拼团管理',
|
||||||
'/admin/orders': '订单管理',
|
'/admin/orders': '订单管理',
|
||||||
'/admin/users': '用户管理',
|
'/admin/users': '用户管理',
|
||||||
'/admin/reviews': '评价管理',
|
'/admin/reviews': '评价管理',
|
||||||
@@ -191,6 +197,7 @@ const handleLogout = async () => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
.el-container {
|
.el-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -198,7 +205,8 @@ const handleLogout = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
background-color: #001529;
|
background: #fffaf2;
|
||||||
|
border-right: 1px solid #d8cebf;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
|
|
||||||
.logo-container {
|
.logo-container {
|
||||||
@@ -207,46 +215,52 @@ const handleLogout = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: white;
|
color: #171715;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid #d8cebf;
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
color: #ef4444;
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
background-color: #001529;
|
background: transparent;
|
||||||
|
padding: 12px 10px;
|
||||||
|
|
||||||
:deep(.el-menu-item) {
|
:deep(.el-menu-item) {
|
||||||
color: rgba(255, 255, 255, 0.65);
|
color: #171715;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
background-color: #f4ede4 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
color: white;
|
color: #171715;
|
||||||
background-color: #1890ff !important;
|
background-color: #fffdf8 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-header {
|
.admin-header {
|
||||||
background-color: white;
|
background: rgba(255, 250, 242, 0.92);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
backdrop-filter: none;
|
||||||
|
border-bottom: 1px solid #d8cebf;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 24px;
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -258,7 +272,7 @@ const handleLogout = async () => {
|
|||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #1890ff;
|
color: #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +288,7 @@ const handleLogout = async () => {
|
|||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #1890ff;
|
color: #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,17 +297,22 @@ const handleLogout = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fffaf2;
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: #2b2b27;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-main {
|
.admin-main {
|
||||||
background-color: #f0f2f5;
|
background: transparent;
|
||||||
padding: 20px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动画
|
// 动画
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import AppFooter from '@/components/common/AppFooter.vue'
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-top: 60px; // header高度
|
padding-top: 60px; // header高度
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路由切换动画
|
// 路由切换动画
|
||||||
@@ -45,4 +45,4 @@ import AppFooter from '@/components/common/AppFooter.vue'
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(30px);
|
transform: translateX(30px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="admin-dashboard">
|
<div class="admin-dashboard">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
<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-card">
|
||||||
<div class="stat-icon bg-blue-100 text-blue-500">
|
<div class="stat-icon tone-1">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon bg-emerald-100 text-emerald-500">
|
<div class="stat-icon tone-2">
|
||||||
<el-icon><ShoppingBag /></el-icon>
|
<el-icon><ShoppingBag /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon bg-orange-100 text-orange-500">
|
<div class="stat-icon tone-3">
|
||||||
<el-icon><List /></el-icon>
|
<el-icon><List /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon bg-rose-100 text-rose-500">
|
<div class="stat-icon tone-4">
|
||||||
<el-icon><Coin /></el-icon>
|
<el-icon><Coin /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -212,7 +212,7 @@ const renderSalesChart = () => {
|
|||||||
data: recentOrders.value.map((item) => item.totalAmount),
|
data: recentOrders.value.map((item) => item.totalAmount),
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: [6, 6, 0, 0],
|
borderRadius: [6, 6, 0, 0],
|
||||||
color: '#3b82f6',
|
color: '#171715',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -228,6 +228,7 @@ const renderCategoryChart = () => {
|
|||||||
categoryChart.setOption({
|
categoryChart.setOption({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
legend: { bottom: 0 },
|
legend: { bottom: 0 },
|
||||||
|
color: ['#171715', '#5e5e58', '#9f9f99'],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '商品状态',
|
name: '商品状态',
|
||||||
@@ -288,11 +289,28 @@ onUnmounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.admin-dashboard {
|
.admin-dashboard {
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@apply bg-white rounded-xl p-5 shadow-sm flex items-center gap-4;
|
@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 {
|
.stat-icon {
|
||||||
@apply w-12 h-12 rounded-xl flex items-center justify-center text-xl;
|
@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 {
|
.stat-value {
|
||||||
@@ -308,7 +326,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@apply bg-white rounded-xl shadow-sm p-5;
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
|||||||
@@ -93,14 +93,10 @@ onMounted(() => { reloadData() })
|
|||||||
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
||||||
.actions { display:flex; gap:12px; }
|
.actions { display:flex; gap:12px; }
|
||||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
||||||
.mini-stat { @apply rounded-xl text-white p-5 shadow-sm; }
|
.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.blue { background:linear-gradient(135deg,#3b82f6,#2563eb); }
|
|
||||||
.mini-stat.green { background:linear-gradient(135deg,#10b981,#059669); }
|
|
||||||
.mini-stat.orange { background:linear-gradient(135deg,#f59e0b,#ea580c); }
|
|
||||||
.mini-stat.purple { background:linear-gradient(135deg,#8b5cf6,#7c3aed); }
|
|
||||||
.mini-stat__value { @apply text-3xl font-bold; }
|
.mini-stat__value { @apply text-3xl font-bold; }
|
||||||
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
||||||
.panel-card { @apply bg-white rounded-xl shadow-sm p-5; }
|
.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; }
|
.filter-card { display:grid; grid-template-columns:1fr 100px; gap:12px; }
|
||||||
.table-footer { @apply flex justify-end mt-4; }
|
.table-footer { @apply flex justify-end mt-4; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -128,10 +128,22 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="开始时间" prop="startTime">
|
<el-form-item label="开始时间" prop="startTime">
|
||||||
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
|
<el-date-picker
|
||||||
|
v-model="form.startTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:disabled-date="disablePastDate"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="结束时间" prop="endTime">
|
<el-form-item label="结束时间" prop="endTime">
|
||||||
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
|
<el-date-picker
|
||||||
|
v-model="form.endTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:disabled-date="disablePastDate"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -187,6 +199,9 @@ const formRef = ref<FormInstance>()
|
|||||||
const flashSales = ref<FlashSale[]>([])
|
const flashSales = ref<FlashSale[]>([])
|
||||||
const currentItem = ref<FlashSale | null>(null)
|
const currentItem = ref<FlashSale | null>(null)
|
||||||
const productOptions = ref<AdminProductRow[]>([])
|
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({
|
const query = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -206,13 +221,16 @@ const stats = reactive<AdminFlashSaleStats>({
|
|||||||
endedFlashSales: 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({
|
const form = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
productId: undefined as number | undefined,
|
productId: undefined as number | undefined,
|
||||||
flashPrice: 0.01,
|
flashPrice: 0.01,
|
||||||
flashStock: 1,
|
flashStock: 1,
|
||||||
startTime: '',
|
startTime: buildDefaultStartTime(),
|
||||||
endTime: '',
|
endTime: buildDefaultEndTime(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
@@ -254,13 +272,38 @@ const getStockRate = (item: FlashSale) => {
|
|||||||
return Math.round((item.remainingStock / item.flashStock) * 100)
|
return Math.round((item.remainingStock / item.flashStock) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disablePastDate = (date: Date) => dayjs(date).endOf('day').isBefore(dayjs())
|
||||||
|
|
||||||
|
const validateTimeRange = () => {
|
||||||
|
const now = dayjs()
|
||||||
|
const startTime = dayjs(form.startTime)
|
||||||
|
const endTime = dayjs(form.endTime)
|
||||||
|
|
||||||
|
if (!startTime.isValid() || !endTime.isValid()) {
|
||||||
|
ElMessage.error('开始时间或结束时间格式无效')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime.isAfter(now)) {
|
||||||
|
ElMessage.error('开始时间必须晚于当前时间')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endTime.isAfter(startTime)) {
|
||||||
|
ElMessage.error('结束时间必须晚于开始时间')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.id = 0
|
form.id = 0
|
||||||
form.productId = undefined
|
form.productId = undefined
|
||||||
form.flashPrice = 0.01
|
form.flashPrice = 0.01
|
||||||
form.flashStock = 1
|
form.flashStock = 1
|
||||||
form.startTime = ''
|
form.startTime = buildDefaultStartTime()
|
||||||
form.endTime = ''
|
form.endTime = buildDefaultEndTime(form.startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
@@ -315,6 +358,7 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
if (!validateTimeRange()) return
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
@@ -435,19 +479,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
@apply rounded-xl text-white p-5 shadow-sm;
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
color: #171715;
|
||||||
&.red { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
border: 1px solid #d8cebf;
|
||||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
&.gray { background: linear-gradient(135deg, #64748b, #475569); }
|
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value { @apply text-3xl font-bold; }
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
&__label { @apply text-sm opacity-90 mt-2; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@apply bg-white rounded-xl shadow-sm p-5;
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-card {
|
.filter-card {
|
||||||
@@ -468,7 +513,7 @@ onMounted(() => {
|
|||||||
height: 56px;
|
height: 56px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-image {
|
.detail-image {
|
||||||
@@ -499,7 +544,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flash-price {
|
.flash-price {
|
||||||
@apply text-3xl font-bold text-rose-500;
|
@apply text-3xl font-bold;
|
||||||
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.origin-price {
|
.origin-price {
|
||||||
|
|||||||
485
flash-sale-frontend/src/pages/admin/groupbuying.vue
Normal file
485
flash-sale-frontend/src/pages/admin/groupbuying.vue
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
<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 prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column label="商品" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="product-cell">
|
||||||
|
<SafeImage :src="row.productImageUrl" :alt="row.productName" wrapper-class="product-image" img-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 prop="requiredMembers" label="成团人数" 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 label="操作" width="240" fixed="right">
|
||||||
|
<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-button text type="danger" @click="removeActivity(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"
|
||||||
|
:total="pagination.total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
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" filterable :disabled="!!editingId" placeholder="请选择商品" class="w-full">
|
||||||
|
<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" :min="2" :max="100" 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" :min="1" :max="10" class="w-full" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="有效期(分钟)">
|
||||||
|
<el-input-number v-model="form.durationMinutes" :min="1" :max="10080" class="w-full" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始时间" prop="startTime">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.startTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:disabled-date="disablePastDate"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束时间" prop="endTime">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.endTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:disabled-date="disablePastDate"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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) => dayjs(date).endOf('day').isBefore(dayjs())
|
||||||
|
|
||||||
|
const validateTimeRange = () => {
|
||||||
|
const now = dayjs()
|
||||||
|
const startTime = dayjs(form.startTime)
|
||||||
|
const endTime = dayjs(form.endTime)
|
||||||
|
|
||||||
|
if (!startTime.isValid() || !endTime.isValid()) {
|
||||||
|
ElMessage.error('开始时间或结束时间格式无效')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime.isAfter(now)) {
|
||||||
|
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 scoped lang="scss">
|
||||||
|
.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>
|
||||||
@@ -233,6 +233,7 @@ const renderChart = () => {
|
|||||||
|
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
|
color: ['#171715', '#5e5e58', '#9f9f99'],
|
||||||
legend: { top: 0 },
|
legend: { top: 0 },
|
||||||
grid: { left: 24, right: 24, top: 40, bottom: 24, containLabel: true },
|
grid: { left: 24, right: 24, top: 40, bottom: 24, containLabel: true },
|
||||||
xAxis: { type: 'category', data: history.time },
|
xAxis: { type: 'category', data: history.time },
|
||||||
@@ -369,12 +370,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
@apply rounded-xl text-white p-5 shadow-sm;
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
color: #171715;
|
||||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
border: 1px solid #d8cebf;
|
||||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value { @apply text-3xl font-bold; }
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
&__label { @apply text-sm opacity-90 mt-2; }
|
||||||
@@ -387,7 +387,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@apply bg-white rounded-xl shadow-sm p-5;
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -413,8 +415,9 @@ onUnmounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
background: #f8fafc;
|
background: #f4ede4;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-name {
|
.service-name {
|
||||||
@@ -431,8 +434,8 @@ onUnmounted(() => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot.success { background: #10b981; }
|
.dot.success { background: #171715; }
|
||||||
.dot.danger { background: #ef4444; }
|
.dot.danger { background: #666666; }
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
height: 320px;
|
height: 320px;
|
||||||
@@ -445,8 +448,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.business-item {
|
.business-item {
|
||||||
background: #f8fafc;
|
background: #f4ede4;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -475,13 +479,14 @@ onUnmounted(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
background: #0f172a;
|
background: #fffaf2;
|
||||||
color: #e2e8f0;
|
color: #171715;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-time {
|
.log-time {
|
||||||
color: #94a3b8;
|
color: #666666;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -315,19 +315,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
@apply rounded-xl text-white p-5 shadow-sm;
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
color: #171715;
|
||||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
border: 1px solid #d8cebf;
|
||||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value { @apply text-3xl font-bold; }
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
&__label { @apply text-sm opacity-90 mt-2; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@apply bg-white rounded-xl shadow-sm p-5;
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-card {
|
.filter-card {
|
||||||
@@ -367,8 +368,9 @@ onMounted(() => {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #f8fafc;
|
background: #f4ede4;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-image {
|
.item-image {
|
||||||
@@ -387,7 +389,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-total {
|
.item-total {
|
||||||
@apply text-lg font-semibold text-rose-500;
|
@apply text-lg font-semibold;
|
||||||
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
|
|||||||
@@ -409,12 +409,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
@apply rounded-xl text-white p-5 shadow-sm;
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
color: #171715;
|
||||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
border: 1px solid #d8cebf;
|
||||||
&.gray { background: linear-gradient(135deg, #64748b, #475569); }
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
@apply text-3xl font-bold;
|
@apply text-3xl font-bold;
|
||||||
@@ -426,7 +425,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@apply bg-white rounded-xl shadow-sm p-5;
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-card {
|
.filter-card {
|
||||||
@@ -458,7 +459,7 @@ onMounted(() => {
|
|||||||
height: 220px;
|
height: 220px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-content h3 {
|
.detail-content h3 {
|
||||||
@@ -466,7 +467,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-price {
|
.detail-price {
|
||||||
@apply text-3xl font-bold text-rose-500 mt-3 mb-4;
|
@apply text-3xl font-bold mt-3 mb-4;
|
||||||
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
@@ -481,7 +483,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-description {
|
.detail-description {
|
||||||
@apply mt-5 text-sm leading-6 text-slate-600 bg-slate-50 rounded-xl p-4;
|
@apply mt-5 text-sm leading-6 text-slate-600 rounded-xl p-4;
|
||||||
|
background: #f4ede4;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
|||||||
@@ -110,14 +110,10 @@ onMounted(() => { reloadData() })
|
|||||||
.page-title { @apply text-2xl font-bold text-slate-900; }
|
.page-title { @apply text-2xl font-bold text-slate-900; }
|
||||||
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
||||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
||||||
.mini-stat { @apply rounded-xl text-white p-5 shadow-sm; }
|
.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.blue { background:linear-gradient(135deg,#3b82f6,#2563eb); }
|
|
||||||
.mini-stat.green { background:linear-gradient(135deg,#10b981,#059669); }
|
|
||||||
.mini-stat.orange { background:linear-gradient(135deg,#f59e0b,#ea580c); }
|
|
||||||
.mini-stat.purple { background:linear-gradient(135deg,#8b5cf6,#7c3aed); }
|
|
||||||
.mini-stat__value { @apply text-3xl font-bold; }
|
.mini-stat__value { @apply text-3xl font-bold; }
|
||||||
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
||||||
.panel-card { @apply bg-white rounded-xl shadow-sm p-5; }
|
.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; }
|
.filter-card { display:grid; grid-template-columns:1fr 100px; gap:12px; }
|
||||||
.table-footer { @apply flex justify-end mt-4; }
|
.table-footer { @apply flex justify-end mt-4; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -239,19 +239,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
@apply rounded-xl text-white p-5 shadow-sm;
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
color: #171715;
|
||||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
border: 1px solid #d8cebf;
|
||||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value { @apply text-3xl font-bold; }
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
&__label { @apply text-sm opacity-90 mt-2; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@apply bg-white rounded-xl shadow-sm p-5;
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-card {
|
.filter-card {
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.cart-page {
|
.cart-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
|
|||||||
@@ -39,12 +39,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold mb-4">{{ flashSale.productName }}</h1>
|
<h1 class="text-3xl font-bold mb-4">{{ flashSale.productName }}</h1>
|
||||||
|
|
||||||
<div class="bg-red-50 rounded-lg p-6 mb-6">
|
<div class="price-card rounded-lg p-6 mb-6">
|
||||||
<div class="flex items-end mb-2">
|
<div class="flex items-end mb-2">
|
||||||
<span class="text-sm text-gray-500 mr-2">秒杀价</span>
|
<span class="text-sm text-gray-500 mr-2">秒杀价</span>
|
||||||
<span class="text-4xl font-bold text-red-500">¥{{ flashSale.flashPrice }}</span>
|
<span class="detail-price">¥{{ flashSale.flashPrice }}</span>
|
||||||
<span class="ml-4 text-lg text-gray-400 line-through">¥{{ flashSale.originalPrice }}</span>
|
<span class="ml-4 text-lg text-gray-400 line-through">¥{{ flashSale.originalPrice }}</span>
|
||||||
<span class="ml-2 px-2 py-1 bg-red-500 text-white text-sm rounded">{{ discountPercent }}% OFF</span>
|
<span class="discount-pill">{{ discountPercent }}% OFF</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600 mt-4">
|
<div class="text-sm text-gray-600 mt-4">
|
||||||
<p>开始时间:{{ formatTime(flashSale.startTime) }}</p>
|
<p>开始时间:{{ formatTime(flashSale.startTime) }}</p>
|
||||||
@@ -67,15 +67,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6 p-4 bg-blue-50 rounded-lg">
|
<div class="note-card mb-6 p-4 rounded-lg">
|
||||||
<div class="flex items-center text-blue-700">
|
<div class="flex items-center">
|
||||||
<el-icon class="mr-2"><InfoFilled /></el-icon>
|
<el-icon class="mr-2"><InfoFilled /></el-icon>
|
||||||
<span>每人限购 {{ flashSale.limitPerUser }} 件</span>
|
<span>每人限购 {{ flashSale.limitPerUser }} 件</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<el-button type="danger" size="large" class="w-full" :disabled="!canParticipate" :loading="participating" @click="handleParticipate">
|
<el-button type="primary" size="large" class="w-full" :disabled="!canParticipate" :loading="participating" @click="handleParticipate">
|
||||||
<el-icon class="mr-2"><Lightning /></el-icon>
|
<el-icon class="mr-2"><Lightning /></el-icon>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 p-4 bg-yellow-50 rounded-lg">
|
<div class="rules-card mt-8 p-4 rounded-lg">
|
||||||
<h3 class="font-semibold mb-2">抢购说明</h3>
|
<h3 class="font-semibold mb-2">抢购说明</h3>
|
||||||
<ul class="text-sm text-gray-600 space-y-1">
|
<ul class="text-sm text-gray-600 space-y-1">
|
||||||
<li>• 秒杀商品数量有限,先到先得</li>
|
<li>• 秒杀商品数量有限,先到先得</li>
|
||||||
@@ -158,9 +158,9 @@ const stockPercent = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const progressColor = computed(() => {
|
const progressColor = computed(() => {
|
||||||
if (stockPercent.value > 50) return '#67c23a'
|
if (stockPercent.value > 50) return '#171715'
|
||||||
if (stockPercent.value > 20) return '#e6a23c'
|
if (stockPercent.value > 20) return '#5e5e58'
|
||||||
return '#f56c6c'
|
return '#9f9f99'
|
||||||
})
|
})
|
||||||
|
|
||||||
const endTime = computed(() => {
|
const endTime = computed(() => {
|
||||||
@@ -243,6 +243,30 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.flashsale-detail-page {
|
.flashsale-detail-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
||||||
<el-icon class="text-red-500 mr-2"><Lightning /></el-icon>
|
<el-icon class="page-icon mr-2"><Lightning /></el-icon>
|
||||||
秒杀活动
|
秒杀活动
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600">限时抢购,先到先得</p>
|
<p class="text-gray-600">限时抢购,先到先得</p>
|
||||||
@@ -59,22 +59,22 @@
|
|||||||
|
|
||||||
<!-- 统计信息 -->
|
<!-- 统计信息 -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div class="stat-card bg-gradient-to-r from-orange-400 to-red-500">
|
<div class="stat-card tone-1">
|
||||||
<div class="stat-value">{{ statistics.upcoming }}</div>
|
<div class="stat-value">{{ statistics.upcoming }}</div>
|
||||||
<div class="stat-label">即将开始</div>
|
<div class="stat-label">即将开始</div>
|
||||||
<el-icon :size="30" class="stat-icon"><Clock /></el-icon>
|
<el-icon :size="30" class="stat-icon"><Clock /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card bg-gradient-to-r from-green-400 to-blue-500">
|
<div class="stat-card tone-2">
|
||||||
<div class="stat-value">{{ statistics.active }}</div>
|
<div class="stat-value">{{ statistics.active }}</div>
|
||||||
<div class="stat-label">正在进行</div>
|
<div class="stat-label">正在进行</div>
|
||||||
<el-icon :size="30" class="stat-icon"><Lightning /></el-icon>
|
<el-icon :size="30" class="stat-icon"><Lightning /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card bg-gradient-to-r from-purple-400 to-pink-500">
|
<div class="stat-card tone-3">
|
||||||
<div class="stat-value">{{ statistics.participated }}</div>
|
<div class="stat-value">{{ statistics.participated }}</div>
|
||||||
<div class="stat-label">我的参与</div>
|
<div class="stat-label">我的参与</div>
|
||||||
<el-icon :size="30" class="stat-icon"><Trophy /></el-icon>
|
<el-icon :size="30" class="stat-icon"><Trophy /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card bg-gradient-to-r from-yellow-400 to-orange-500">
|
<div class="stat-card tone-4">
|
||||||
<div class="stat-value">{{ statistics.success }}</div>
|
<div class="stat-value">{{ statistics.success }}</div>
|
||||||
<div class="stat-label">抢购成功</div>
|
<div class="stat-label">抢购成功</div>
|
||||||
<el-icon :size="30" class="stat-icon"><SuccessFilled /></el-icon>
|
<el-icon :size="30" class="stat-icon"><SuccessFilled /></el-icon>
|
||||||
@@ -166,13 +166,10 @@ const loadFlashSales = async () => {
|
|||||||
page: pagination.page - 1,
|
page: pagination.page - 1,
|
||||||
size: pagination.size
|
size: pagination.size
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
flashSales.value = res.data.content
|
flashSales.value = res.data.content
|
||||||
pagination.total = res.data.totalElements
|
pagination.total = res.data.totalElements
|
||||||
|
|
||||||
// 更新统计信息
|
|
||||||
updateStatistics()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载秒杀活动失败:', error)
|
console.error('加载秒杀活动失败:', error)
|
||||||
@@ -181,27 +178,18 @@ const loadFlashSales = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新统计信息
|
// 加载统计信息(从后端获取真实数据)
|
||||||
const updateStatistics = () => {
|
const loadStatistics = async () => {
|
||||||
statistics.upcoming = flashSales.value.filter(item => item.status === 'UPCOMING').length
|
|
||||||
statistics.active = flashSales.value.filter(item => item.status === 'ACTIVE').length
|
|
||||||
|
|
||||||
// 获取用户参与记录(需要后端API支持)
|
|
||||||
if (userStore.isLoggedIn) {
|
|
||||||
loadUserStatistics()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载用户统计
|
|
||||||
const loadUserStatistics = async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await flashsaleApi.getUserRecords()
|
const res = await flashsaleApi.getStatistics()
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
statistics.participated = res.data.length
|
statistics.upcoming = res.data.upcoming ?? 0
|
||||||
statistics.success = res.data.filter((item: any) => item.success).length
|
statistics.active = res.data.active ?? 0
|
||||||
|
statistics.participated = res.data.participated ?? 0
|
||||||
|
statistics.success = res.data.success ?? 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载用户统计失败:', error)
|
console.error('加载统计信息失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,33 +229,44 @@ const handleParticipate = async (flashSaleId: number) => {
|
|||||||
// 刷新
|
// 刷新
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadFlashSales()
|
loadFlashSales()
|
||||||
|
loadStatistics()
|
||||||
ElMessage.success('已刷新')
|
ElMessage.success('已刷新')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadFlashSales()
|
loadFlashSales()
|
||||||
|
loadStatistics()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.flashsale-page {
|
.flashsale-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-icon {
|
||||||
|
color: #44443f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@apply relative overflow-hidden rounded-lg p-4 text-white;
|
@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 {
|
.stat-value {
|
||||||
@apply text-2xl font-bold;
|
@apply text-2xl font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
@apply text-sm opacity-90 mt-1;
|
@apply text-sm mt-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
@apply absolute right-4 bottom-4 opacity-30;
|
@apply absolute right-4 bottom-4;
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
219
flash-sale-frontend/src/pages/groupbuying/detail.vue
Normal file
219
flash-sale-frontend/src/pages/groupbuying/detail.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container py-8">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<!-- 面包屑 -->
|
||||||
|
<el-breadcrumb separator="/" class="mb-6">
|
||||||
|
<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 class="is-loading" :size="32"><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
|
||||||
|
:src="detail.productImageUrl"
|
||||||
|
:alt="detail.productName"
|
||||||
|
wrapper-class="w-full h-96 rounded-2xl overflow-hidden"
|
||||||
|
img-class="w-full h-96 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<el-tag :type="statusType" effect="dark" class="mb-3">{{ 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 type="danger" size="small">省 ¥{{ 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 :percentage="stockPercent" :stroke-width="8" :show-text="false" :color="progressColor" class="mb-6" />
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<el-button type="primary" size="large" :disabled="!canJoin" @click="handleCreateGroup" :loading="joining">
|
||||||
|
<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">
|
||||||
|
<h2 class="text-xl font-bold mb-4">
|
||||||
|
进行中的团组
|
||||||
|
<span class="text-sm text-gray-400 ml-2">({{ groups.length }} 个)</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="groups.length === 0" class="text-center py-10">
|
||||||
|
<el-empty description="暂无进行中的团组,快来开团吧!" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div v-for="group in groups" :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 type="primary" size="small" @click="handleJoinGroup(group.id)" :loading="joining">
|
||||||
|
参团
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Loading } 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 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.filter(g => g.status === 'FORMING')
|
||||||
|
} 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 (e: any) {
|
||||||
|
ElMessage.error(e.message || '开团失败')
|
||||||
|
} 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 (e: any) {
|
||||||
|
ElMessage.error(e.message || '加入失败')
|
||||||
|
} finally {
|
||||||
|
joining.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDetail)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.price-section {
|
||||||
|
@apply p-4 rounded-xl;
|
||||||
|
background: #fffaf2;
|
||||||
|
border: 1px solid #e8e0d4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
167
flash-sale-frontend/src/pages/groupbuying/group.vue
Normal file
167
flash-sale-frontend/src/pages/groupbuying/group.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container py-8">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<!-- 面包屑 -->
|
||||||
|
<el-breadcrumb separator="/" class="mb-6">
|
||||||
|
<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 class="is-loading" :size="32"><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" effect="dark" size="large" class="mb-4">{{ 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
|
||||||
|
:members="group.members"
|
||||||
|
:required-members="group.requiredMembers"
|
||||||
|
:leader-user-id="group.leaderUserId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex gap-3 justify-center">
|
||||||
|
<el-button v-if="group.status === 'FORMING' && !isInGroup" type="primary" size="large" @click="handleJoin" :loading="joining">
|
||||||
|
<el-icon class="mr-1"><Connection /></el-icon>
|
||||||
|
加入拼团
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="group.status === 'FORMING' && isInGroup" type="danger" size="large" @click="handleCancel" :loading="cancelling">
|
||||||
|
退出团组
|
||||||
|
</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 setup lang="ts">
|
||||||
|
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 (e: any) {
|
||||||
|
ElMessage.error(e.message || '加入失败')
|
||||||
|
} 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') {
|
||||||
|
ElMessage.error(e.message || '退出失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cancelling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const url = window.location.href
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
ElMessage.success('链接已复制,快分享给好友吧!')
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.info('请手动复制链接分享')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadGroup)
|
||||||
|
</script>
|
||||||
148
flash-sale-frontend/src/pages/groupbuying/index.vue
Normal file
148
flash-sale-frontend/src/pages/groupbuying/index.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<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" @click="loadList" :loading="loading">刷新</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 class="is-loading" :size="32"><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 setup lang="ts">
|
||||||
|
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 scoped lang="scss">
|
||||||
|
.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>
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
<div class="container mx-auto px-4 h-full">
|
<div class="container mx-auto px-4 h-full">
|
||||||
<div class="flex items-center h-full">
|
<div class="flex items-center h-full">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<h1 class="text-4xl font-bold text-white mb-4">
|
<h1 class="banner-title text-4xl font-bold mb-4">
|
||||||
<el-icon :size="40"><Lightning /></el-icon>
|
<el-icon :size="40"><Lightning /></el-icon>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl text-white mb-6">{{ item.subtitle }}</p>
|
<p class="banner-subtitle text-xl mb-6">{{ item.subtitle }}</p>
|
||||||
<div class="space-x-4">
|
<div class="space-x-4">
|
||||||
<el-button size="large" type="primary" @click="router.push(item.link)">
|
<el-button size="large" type="primary" @click="router.push(item.link)">
|
||||||
{{ item.buttonText }}
|
{{ item.buttonText }}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 text-center">
|
<div class="w-1/2 text-center">
|
||||||
<el-icon :size="200" class="text-white opacity-50">
|
<el-icon :size="200" class="banner-illustration">
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,11 +33,43 @@
|
|||||||
</el-carousel>
|
</el-carousel>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
<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">
|
<section class="mb-12">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold flex items-center">
|
<h2 class="text-2xl font-bold flex items-center">
|
||||||
<el-icon class="text-red-500 mr-2"><Lightning /></el-icon>
|
<el-icon class="section-icon mr-2"><Lightning /></el-icon>
|
||||||
正在秒杀
|
正在秒杀
|
||||||
</h2>
|
</h2>
|
||||||
<el-button text @click="router.push('/flashsale')">
|
<el-button text @click="router.push('/flashsale')">
|
||||||
@@ -69,7 +101,7 @@
|
|||||||
<section class="mb-12">
|
<section class="mb-12">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold flex items-center">
|
<h2 class="text-2xl font-bold flex items-center">
|
||||||
<el-icon class="text-orange-500 mr-2"><Star /></el-icon>
|
<el-icon class="section-icon mr-2"><Star /></el-icon>
|
||||||
热门商品
|
热门商品
|
||||||
</h2>
|
</h2>
|
||||||
<el-button text @click="router.push('/products')">
|
<el-button text @click="router.push('/products')">
|
||||||
@@ -102,22 +134,22 @@
|
|||||||
<h2 class="text-2xl font-bold text-center mb-8">系统特性</h2>
|
<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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="text-red-500 mb-4"><Lightning /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4"><Lightning /></el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">秒杀抢购</h3>
|
<h3 class="text-lg font-semibold mb-2">秒杀抢购</h3>
|
||||||
<p class="text-gray-600">高并发秒杀系统,支持大量用户同时抢购</p>
|
<p class="text-gray-600">高并发秒杀系统,支持大量用户同时抢购</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="text-green-500 mb-4"><Lock /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4"><Lock /></el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">防超卖</h3>
|
<h3 class="text-lg font-semibold mb-2">防超卖</h3>
|
||||||
<p class="text-gray-600">分布式锁机制,确保库存数据一致性</p>
|
<p class="text-gray-600">分布式锁机制,确保库存数据一致性</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="text-blue-500 mb-4"><Coin /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4"><Coin /></el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">Redis缓存</h3>
|
<h3 class="text-lg font-semibold mb-2">Redis缓存</h3>
|
||||||
<p class="text-gray-600">五种数据类型应用,毫秒级响应</p>
|
<p class="text-gray-600">五种数据类型应用,毫秒级响应</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="text-orange-500 mb-4"><Odometer /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4"><Odometer /></el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
|
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
|
||||||
<p class="text-gray-600">多种限流策略,防止恶意刷单</p>
|
<p class="text-gray-600">多种限流策略,防止恶意刷单</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +183,7 @@ const banners = [
|
|||||||
subtitle: '基于Redis集群构建的高并发秒杀系统',
|
subtitle: '基于Redis集群构建的高并发秒杀系统',
|
||||||
buttonText: '立即抢购',
|
buttonText: '立即抢购',
|
||||||
link: '/flashsales',
|
link: '/flashsales',
|
||||||
bgColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
bgColor: '#ffffff',
|
||||||
icon: 'Lightning'
|
icon: 'Lightning'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,7 +192,7 @@ const banners = [
|
|||||||
subtitle: '采用分布式锁和Lua脚本,确保数据一致性',
|
subtitle: '采用分布式锁和Lua脚本,确保数据一致性',
|
||||||
buttonText: '了解更多',
|
buttonText: '了解更多',
|
||||||
link: '/flashsales',
|
link: '/flashsales',
|
||||||
bgColor: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
bgColor: '#ffffff',
|
||||||
icon: 'Lock'
|
icon: 'Lock'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,17 +201,51 @@ const banners = [
|
|||||||
subtitle: 'Redis集群架构,毫秒级响应',
|
subtitle: 'Redis集群架构,毫秒级响应',
|
||||||
buttonText: '查看商品',
|
buttonText: '查看商品',
|
||||||
link: '/products',
|
link: '/products',
|
||||||
bgColor: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
bgColor: '#ffffff',
|
||||||
icon: 'Odometer'
|
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 loadingFlashSales = ref(false)
|
||||||
const loadingProducts = ref(false)
|
const loadingProducts = ref(false)
|
||||||
|
const categoryList = ref<{ name: string; icon: string }[]>([])
|
||||||
const activeFlashSales = ref<FlashSale[]>([])
|
const activeFlashSales = ref<FlashSale[]>([])
|
||||||
const hotProducts = ref<Product[]>([])
|
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 () => {
|
const loadFlashSales = async () => {
|
||||||
loadingFlashSales.value = true
|
loadingFlashSales.value = true
|
||||||
@@ -234,6 +300,7 @@ const handleAddToCart = async (productId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadCategories()
|
||||||
loadFlashSales()
|
loadFlashSales()
|
||||||
loadProducts()
|
loadProducts()
|
||||||
})
|
})
|
||||||
@@ -248,10 +315,48 @@ onMounted(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.feature-card {
|
||||||
@apply bg-white p-6 rounded-lg shadow-md text-center hover:shadow-lg transition-shadow;
|
@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) {
|
:deep(.el-carousel__item) {
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
|
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="order.status === 'COMPLETED'">
|
<template v-else-if="order.status === 'COMPLETED'">
|
||||||
<el-button @click="handleReview">评价</el-button>
|
<el-button v-if="allReviewed" @click="reviewDialogVisible = true">查看评价</el-button>
|
||||||
|
<el-button v-else type="primary" @click="reviewDialogVisible = true">评价</el-button>
|
||||||
<el-button @click="handleRebuy">再次购买</el-button>
|
<el-button @click="handleRebuy">再次购买</el-button>
|
||||||
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,6 +94,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReviewDialog
|
||||||
|
v-if="order"
|
||||||
|
v-model:visible="reviewDialogVisible"
|
||||||
|
:order-id="order.id"
|
||||||
|
:order-items="order.items"
|
||||||
|
@success="checkAllReviewed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -107,6 +116,7 @@ import { useCartStore } from '@/stores/cart'
|
|||||||
import type { Order } from '@/types/api'
|
import type { Order } from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -114,6 +124,8 @@ const cartStore = useCartStore()
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
|
const reviewDialogVisible = ref(false)
|
||||||
|
const allReviewed = ref(false)
|
||||||
|
|
||||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
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', REFUNDED: 'danger' }[status] || 'info')
|
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDED: 'danger' }[status] || 'info')
|
||||||
@@ -168,16 +180,15 @@ const handleConfirm = async () => {
|
|||||||
try { await orderApi.confirm(order.value.id); ElMessage.success('已确认收货'); loadOrderDetail() } catch (error) { console.error('确认收货失败:', error) }
|
try { await orderApi.confirm(order.value.id); ElMessage.success('已确认收货'); loadOrderDetail() } catch (error) { console.error('确认收货失败:', error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReview = async () => {
|
const checkAllReviewed = async () => {
|
||||||
if (!order.value) return
|
if (!order.value || order.value.status !== 'COMPLETED') return
|
||||||
const firstItem = order.value.items[0]
|
|
||||||
if (!firstItem) return
|
|
||||||
try {
|
try {
|
||||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', { inputType: 'textarea', inputPlaceholder: '分享一下你的使用感受吧', confirmButtonText: '提交评价', cancelButtonText: '取消' })
|
const checks = await Promise.all(
|
||||||
await reviewApi.create({ orderId: order.value.id, productId: firstItem.productId, rating: 5, content: value })
|
order.value.items.map(item => reviewApi.checkReview(order.value!.id, item.productId).catch(() => null))
|
||||||
ElMessage.success('评价提交成功')
|
)
|
||||||
} catch (error) {
|
allReviewed.value = checks.every(res => res?.success && res.data.reviewed)
|
||||||
if (error) console.error('提交评价失败:', error)
|
} catch {
|
||||||
|
allReviewed.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,12 +206,15 @@ const handleDelete = async () => {
|
|||||||
try { await orderApi.delete(order.value.id); ElMessage.success('订单已删除'); router.push('/orders') } catch (error) { console.error('删除订单失败:', error) }
|
try { await orderApi.delete(order.value.id); ElMessage.success('订单已删除'); router.push('/orders') } catch (error) { console.error('删除订单失败:', error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadOrderDetail() })
|
onMounted(async () => {
|
||||||
|
await loadOrderDetail()
|
||||||
|
await checkAllReviewed()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.order-detail-page {
|
.order-detail-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -85,7 +85,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="order.status === 'COMPLETED'">
|
<template v-else-if="order.status === 'COMPLETED'">
|
||||||
<el-button size="small" @click="handleReview(order)">评价</el-button>
|
<el-button v-if="orderReviewStatus[order.id]" size="small" @click="openReviewDialog(order)">查看评价</el-button>
|
||||||
|
<el-button v-else type="primary" size="small" @click="openReviewDialog(order)">评价</el-button>
|
||||||
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
|
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
|
||||||
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -101,6 +102,14 @@
|
|||||||
<div v-if="orders.length > 0" class="mt-8 flex justify-center">
|
<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" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="loadOrders" @current-change="loadOrders" />
|
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="loadOrders" @current-change="loadOrders" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReviewDialog
|
||||||
|
v-if="currentReviewOrder"
|
||||||
|
v-model:visible="reviewDialogVisible"
|
||||||
|
:order-id="currentReviewOrder.id"
|
||||||
|
:order-items="currentReviewOrder.items"
|
||||||
|
@success="onReviewSuccess"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -115,6 +124,7 @@ import { useCartStore } from '@/stores/cart'
|
|||||||
import type { Order } from '@/types/api'
|
import type { Order } from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const cartStore = useCartStore()
|
const cartStore = useCartStore()
|
||||||
@@ -124,6 +134,9 @@ const orders = ref<Order[]>([])
|
|||||||
|
|
||||||
const filters = reactive({ status: '', keyword: '' })
|
const filters = reactive({ status: '', keyword: '' })
|
||||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
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 orderStats = ref([
|
const orderStats = ref([
|
||||||
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
|
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
|
||||||
@@ -150,6 +163,7 @@ const loadOrders = async () => {
|
|||||||
? list.filter((order) => order.orderNo.toLowerCase().includes(keyword) || order.items.some((item) => item.productName.toLowerCase().includes(keyword)))
|
? list.filter((order) => order.orderNo.toLowerCase().includes(keyword) || order.items.some((item) => item.productName.toLowerCase().includes(keyword)))
|
||||||
: list
|
: list
|
||||||
pagination.total = res.data.totalElements
|
pagination.total = res.data.totalElements
|
||||||
|
checkOrdersReviewStatus(orders.value)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -190,21 +204,30 @@ const handleConfirm = async (order: Order) => {
|
|||||||
try { await orderApi.confirm(order.id); ElMessage.success('已确认收货'); loadOrders(); loadStatistics() } catch (error) { console.error('确认收货失败:', error) }
|
try { await orderApi.confirm(order.id); ElMessage.success('已确认收货'); loadOrders(); loadStatistics() } catch (error) { console.error('确认收货失败:', error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReview = async (order: Order) => {
|
const checkOrdersReviewStatus = async (orderList: Order[]) => {
|
||||||
const firstItem = order.items[0]
|
const completed = orderList.filter(o => o.status === 'COMPLETED')
|
||||||
if (!firstItem) return
|
await Promise.all(
|
||||||
|
completed.map(async (order) => {
|
||||||
try {
|
try {
|
||||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', {
|
const checks = await Promise.all(
|
||||||
inputType: 'textarea',
|
order.items.map(item => reviewApi.checkReview(order.id, item.productId).catch(() => null))
|
||||||
inputPlaceholder: '分享一下你的使用感受吧',
|
)
|
||||||
confirmButtonText: '提交评价',
|
orderReviewStatus.value[order.id] = checks.every(res => res?.success && res.data.reviewed)
|
||||||
cancelButtonText: '取消',
|
} catch {
|
||||||
|
orderReviewStatus.value[order.id] = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
await reviewApi.create({ orderId: order.id, productId: firstItem.productId, rating: 5, content: value })
|
)
|
||||||
ElMessage.success('评价提交成功')
|
}
|
||||||
} catch (error) {
|
|
||||||
if (error) console.error('提交评价失败:', error)
|
const openReviewDialog = (order: Order) => {
|
||||||
|
currentReviewOrder.value = order
|
||||||
|
reviewDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReviewSuccess = () => {
|
||||||
|
if (currentReviewOrder.value) {
|
||||||
|
checkOrdersReviewStatus([currentReviewOrder.value])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +249,6 @@ onMounted(() => { loadOrders(); loadStatistics() })
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.orders-page {
|
.orders-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -163,15 +163,29 @@
|
|||||||
|
|
||||||
<el-tab-pane label="用户评价" name="reviews">
|
<el-tab-pane label="用户评价" name="reviews">
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="mb-4 flex items-center justify-between bg-gray-50 rounded-lg p-4">
|
<div class="mb-6 bg-gray-50 rounded-lg p-4">
|
||||||
<div>
|
<div class="flex items-center gap-8 mb-4">
|
||||||
<div class="text-2xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
<div class="text-center">
|
||||||
<div class="text-sm text-gray-500">累计 {{ reviewSummary.totalReviews }} 条评价</div>
|
<div class="text-3xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
||||||
|
<el-rate :model-value="reviewSummary.averageRating" disabled class="mt-1" />
|
||||||
|
<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)"
|
||||||
|
:stroke-width="12"
|
||||||
|
:show-text="false"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-400 w-10 text-right">{{ getRatingCount(star) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-rate :model-value="reviewSummary.averageRating" disabled show-score text-color="#f59e0b" />
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="reviewSummary.reviews.length > 0" class="space-y-4">
|
<div v-if="reviewSummary.reviews.length > 0" class="space-y-4">
|
||||||
<div v-for="review in reviewSummary.reviews" :key="review.id" class="border rounded-lg p-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="flex items-center justify-between mb-2">
|
||||||
<div class="font-semibold">{{ review.username }}</div>
|
<div class="font-semibold">{{ review.username }}</div>
|
||||||
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
|
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
|
||||||
@@ -183,6 +197,14 @@
|
|||||||
<div>{{ review.adminReply }}</div>
|
<div>{{ review.adminReply }}</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<el-empty v-else description="暂无评价" />
|
<el-empty v-else description="暂无评价" />
|
||||||
</div>
|
</div>
|
||||||
@@ -212,11 +234,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { productApi } from '@/api/modules/product'
|
import { productApi } from '@/api/modules/product'
|
||||||
import { reviewApi } from '@/api/modules/review'
|
import { reviewApi } from '@/api/modules/review'
|
||||||
|
import type { ReviewItem } from '@/api/modules/review'
|
||||||
import { favoriteApi } from '@/api/modules/favorite'
|
import { favoriteApi } from '@/api/modules/favorite'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import { useCartStore } from '@/stores/cart'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -236,15 +259,37 @@ const currentImage = ref('')
|
|||||||
const quantity = ref(1)
|
const quantity = ref(1)
|
||||||
const activeTab = ref('detail')
|
const activeTab = ref('detail')
|
||||||
const isFavorited = ref(false)
|
const isFavorited = ref(false)
|
||||||
const reviewSummary = ref({ averageRating: 0, totalReviews: 0, reviews: [] as Array<{ id: number; username: string; rating: number; content: string; adminReply?: string; createdAt: string }> })
|
const reviewSummary = ref({ averageRating: 0, totalReviews: 0, reviews: [] as ReviewItem[] })
|
||||||
|
const reviewPage = ref(1)
|
||||||
|
const reviewPageSize = 10
|
||||||
const defaultProductImage = DEFAULT_PRODUCT_IMAGE
|
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) => {
|
const formatTime = (time: string) => {
|
||||||
return dayjs(time).format('YYYY-MM-DD')
|
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 () => {
|
const loadProductDetail = async () => {
|
||||||
@@ -345,13 +390,16 @@ const handleFavorite = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProductDetail()
|
loadProductDetail()
|
||||||
|
if (route.query.tab === 'reviews') {
|
||||||
|
activeTab.value = 'reviews'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.product-detail-page {
|
.product-detail-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
@@ -362,4 +410,4 @@ onMounted(() => {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,28 +4,50 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
||||||
<el-icon class="text-blue-500 mr-2"><ShoppingBag /></el-icon>
|
<el-icon class="page-icon mr-2"><ShoppingBag /></el-icon>
|
||||||
商品列表
|
商品列表
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600">精选好物,品质保证</p>
|
<p class="text-gray-600">精选好物,品质保证</p>
|
||||||
</div>
|
</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="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
<!-- 分类筛选 -->
|
<!-- 分类筛选 -->
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.category"
|
v-model="filters.category"
|
||||||
placeholder="选择分类"
|
placeholder="选择分类"
|
||||||
clearable
|
clearable
|
||||||
style="width: 150px"
|
style="width: 150px"
|
||||||
@change="loadProducts"
|
@change="loadProducts"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
:label="cat"
|
:label="cat"
|
||||||
:value="cat"
|
:value="cat"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
@@ -111,7 +133,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import ProductCard from '@/components/business/ProductCard.vue'
|
import ProductCard from '@/components/business/ProductCard.vue'
|
||||||
@@ -197,6 +219,15 @@ const loadCategories = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 选择分类
|
||||||
|
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) => {
|
const handleAddToCart = async (productId: number) => {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
@@ -213,15 +244,46 @@ onMounted(() => {
|
|||||||
if (route.query.keyword) {
|
if (route.query.keyword) {
|
||||||
filters.keyword = route.query.keyword as string
|
filters.keyword = route.query.keyword as string
|
||||||
}
|
}
|
||||||
|
// 从路由参数获取分类
|
||||||
|
if (route.query.category) {
|
||||||
|
filters.category = route.query.category as string
|
||||||
|
}
|
||||||
|
|
||||||
loadCategories()
|
loadCategories()
|
||||||
loadProducts()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.products-page {
|
.products-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.page-icon {
|
||||||
|
color: #44443f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tag {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.favorites-page {
|
.favorites-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-1 {
|
.line-clamp-1 {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-page min-h-screen flex items-center justify-center bg-gray-50">
|
<div class="login-page min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full">
|
||||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
<div class="login-panel bg-white p-8">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<el-icon :size="48" class="text-red-500 mb-4">
|
<el-icon :size="48" class="page-mark mb-4">
|
||||||
<Lightning />
|
<Lightning />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
||||||
@@ -59,20 +59,6 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-divider>或</el-divider>
|
|
||||||
|
|
||||||
<!-- 快速登录 -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<el-button size="large" class="w-full mb-2" @click="quickLogin('user')">
|
|
||||||
<el-icon class="mr-2"><User /></el-icon>
|
|
||||||
普通用户快速登录
|
|
||||||
</el-button>
|
|
||||||
<el-button size="large" class="w-full" @click="quickLogin('admin')">
|
|
||||||
<el-icon class="mr-2"><Setting /></el-icon>
|
|
||||||
管理员快速登录
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-gray-600">还没有账号?</span>
|
<span class="text-gray-600">还没有账号?</span>
|
||||||
<router-link to="/register" class="text-primary-500 hover:underline">
|
<router-link to="/register" class="text-primary-500 hover:underline">
|
||||||
@@ -81,15 +67,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 测试账号提示 -->
|
|
||||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
|
||||||
<h3 class="font-semibold text-blue-900 mb-2">测试账号</h3>
|
|
||||||
<div class="text-sm text-blue-700">
|
|
||||||
<p>普通用户: demo1 / 123456</p>
|
|
||||||
<p>管理员: admin / admin123</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,23 +117,21 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快速登录
|
|
||||||
const quickLogin = (type: 'user' | 'admin') => {
|
|
||||||
if (type === 'user') {
|
|
||||||
loginForm.username = 'demo1'
|
|
||||||
loginForm.password = '123456'
|
|
||||||
} else {
|
|
||||||
loginForm.username = 'admin'
|
|
||||||
loginForm.password = 'admin123'
|
|
||||||
}
|
|
||||||
loginForm.rememberMe = true
|
|
||||||
handleLogin()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.login-page {
|
.login-page {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
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>
|
</style>
|
||||||
|
|||||||
184
flash-sale-frontend/src/pages/user/notifications.vue
Normal file
184
flash-sale-frontend/src/pages/user/notifications.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto py-6 px-4">
|
||||||
|
<!-- 面包屑 -->
|
||||||
|
<el-breadcrumb separator="/" class="mb-6">
|
||||||
|
<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 size="small" @click="handleMarkAllRead" :disabled="unreadCount === 0">
|
||||||
|
全部已读
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleClearAll" :disabled="notifications.length === 0">
|
||||||
|
清空全部
|
||||||
|
</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="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50"
|
||||||
|
:class="{ 'bg-orange-50/50 border-orange-200': !item.read }"
|
||||||
|
@click="handleClick(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<el-icon :size="20" class="mt-0.5" :class="getIconColor(item.type)">
|
||||||
|
<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-medium" :class="{ 'font-semibold': !item.read }">{{ item.title }}</span>
|
||||||
|
<el-tag v-if="!item.read" type="danger" size="small" effect="light">未读</el-tag>
|
||||||
|
<el-tag size="small" effect="plain">{{ 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"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleMarkRead(item)"
|
||||||
|
>
|
||||||
|
标记已读
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<el-empty v-else description="暂无消息通知" class="py-12" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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>
|
||||||
@@ -326,16 +326,15 @@ onMounted(async () => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background-color: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@apply rounded-lg p-5 text-white shadow-sm;
|
@apply rounded-lg p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
color: #171715;
|
||||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
border: 1px solid #d8cebf;
|
||||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="register-page min-h-screen flex items-center justify-center bg-gray-50">
|
<div class="register-page min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full">
|
||||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
<div class="register-panel bg-white p-8">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<el-icon :size="48" class="text-red-500 mb-4">
|
<el-icon :size="48" class="page-mark mb-4">
|
||||||
<Lightning />
|
<Lightning />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
|
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
|
||||||
@@ -192,6 +192,17 @@ const handleRegister = async () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.register-page {
|
.register-page {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.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>
|
||||||
|
|||||||
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-reviews-page">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<el-breadcrumb separator="/" class="mb-6">
|
||||||
|
<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
|
||||||
|
:src="review.productImage"
|
||||||
|
:alt="review.productName"
|
||||||
|
wrapper-class="w-20 h-20 rounded cursor-pointer"
|
||||||
|
img-class="w-20 h-20 object-cover rounded"
|
||||||
|
@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 setup lang="ts">
|
||||||
|
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 scoped lang="scss">
|
||||||
|
.user-reviews-page {
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,6 +80,36 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/user/favorites.vue'),
|
component: () => import('@/pages/user/favorites.vue'),
|
||||||
meta: { title: '我的收藏', requiresAuth: true }
|
meta: { title: '我的收藏', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reviews',
|
||||||
|
name: 'MyReviews',
|
||||||
|
component: () => import('@/pages/user/reviews.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',
|
path: 'addresses',
|
||||||
name: 'Addresses',
|
name: 'Addresses',
|
||||||
@@ -123,6 +153,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/admin/flashsales.vue'),
|
component: () => import('@/pages/admin/flashsales.vue'),
|
||||||
meta: { title: '秒杀管理' }
|
meta: { title: '秒杀管理' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'groupbuying',
|
||||||
|
name: 'AdminGroupBuying',
|
||||||
|
component: () => import('@/pages/admin/groupbuying.vue'),
|
||||||
|
meta: { title: '拼团管理' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'orders',
|
path: 'orders',
|
||||||
name: 'AdminOrders',
|
name: 'AdminOrders',
|
||||||
|
|||||||
@@ -34,16 +34,32 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
token.value = res.data.token
|
token.value = res.data.token
|
||||||
user.value = res.data.user
|
user.value = res.data.user
|
||||||
|
|
||||||
// 保存到localStorage
|
|
||||||
localStorage.setItem('token', token.value)
|
localStorage.setItem('token', token.value)
|
||||||
localStorage.setItem('user', JSON.stringify(user.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('登录成功')
|
ElMessage.success('登录成功')
|
||||||
|
|
||||||
// 跳转到之前的页面或首页
|
|
||||||
const redirect = router.currentRoute.value.query.redirect as string
|
const redirect = router.currentRoute.value.query.redirect as string
|
||||||
router.push(redirect || '/')
|
await router.push(redirect || '/')
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,49 +2,120 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
// 自定义变量
|
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #ef4444;
|
--tone-0: #fffdf8;
|
||||||
--success-color: #10b981;
|
--tone-50: #f7f2ea;
|
||||||
--warning-color: #f59e0b;
|
--tone-100: #efe7dc;
|
||||||
--danger-color: #ef4444;
|
--tone-200: #d8cebf;
|
||||||
--info-color: #3b82f6;
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
background: var(--tone-50);
|
||||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
}
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
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 {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: var(--tone-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #888;
|
background: #b8ab90;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: #8c7e6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动画类
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
@@ -61,34 +132,383 @@ body {
|
|||||||
animation: shake 0.5s;
|
animation: shake 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工具类
|
|
||||||
.text-gradient {
|
.text-gradient {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
color: var(--tone-900);
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-shadow {
|
.card-shadow {
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--line-soft);
|
||||||
transition: all 0.3s;
|
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 {
|
&:hover {
|
||||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
|
border-color: var(--line-strong);
|
||||||
|
box-shadow: var(--shadow-strong);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element Plus 样式覆盖
|
: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 {
|
.el-button--danger {
|
||||||
background-color: var(--primary-color) !important;
|
background-color: var(--surface-raised) !important;
|
||||||
border-color: var(--primary-color) !important;
|
border-color: var(--line-strong) !important;
|
||||||
|
color: var(--tone-900) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box {
|
.el-button:hover,
|
||||||
border-radius: 8px;
|
.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 {
|
.el-notification {
|
||||||
border-radius: 8px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
61
flash-sale-frontend/src/types/api.d.ts
vendored
61
flash-sale-frontend/src/types/api.d.ts
vendored
@@ -155,4 +155,65 @@ export interface Statistics {
|
|||||||
todaySales: number
|
todaySales: number
|
||||||
activeFlashSales: number
|
activeFlashSales: number
|
||||||
onlineUsers: number
|
onlineUsers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼团活动类型
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
CartItem,
|
CartItem,
|
||||||
FlashSale,
|
FlashSale,
|
||||||
|
GroupBuying,
|
||||||
|
GroupBuyingGroup,
|
||||||
Order,
|
Order,
|
||||||
OrderAddress,
|
OrderAddress,
|
||||||
PageResponse,
|
PageResponse,
|
||||||
@@ -285,3 +287,69 @@ export const normalizeAdminProduct = (product: Record<string, any>): AdminProduc
|
|||||||
viewCount: toNumber(product.viewCount),
|
viewCount: toNumber(product.viewCount),
|
||||||
rating: toNumber(product.rating),
|
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 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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
50: '#fef2f2',
|
50: '#f7f7f6',
|
||||||
100: '#fee2e2',
|
100: '#efefed',
|
||||||
200: '#fecaca',
|
200: '#dfdfdc',
|
||||||
300: '#fca5a5',
|
300: '#c6c6c2',
|
||||||
400: '#f87171',
|
400: '#9f9f99',
|
||||||
500: '#ef4444',
|
500: '#7b7b74',
|
||||||
600: '#dc2626',
|
600: '#5e5e58',
|
||||||
700: '#b91c1c',
|
700: '#44443f',
|
||||||
800: '#991b1b',
|
800: '#2b2b27',
|
||||||
900: '#7f1d1d',
|
900: '#171715',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
@@ -26,4 +26,4 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: 'modern-compiler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.org.flashsalesystem;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class FlashSaleSystemApplication {
|
public class FlashSaleSystemApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -292,4 +292,16 @@ public class RedissonConfig {
|
|||||||
log.info("加载购物车操作Lua脚本");
|
log.info("加载购物车操作Lua脚本");
|
||||||
return script;
|
return script;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼团库存扣减Lua脚本
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public DefaultRedisScript<Long> groupBuyingStockScript() {
|
||||||
|
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||||
|
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/groupbuying_stock.lua")));
|
||||||
|
script.setResultType(Long.class);
|
||||||
|
log.info("加载拼团库存扣减Lua脚本");
|
||||||
|
return script;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,31 @@ public class FlashSaleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取秒杀活动统计信息
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<Map<String, Object>> getFlashSaleStatistics(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
Map<String, Object> stats = flashSaleService.getFlashSaleStatistics(userId);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", stats);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取秒杀统计失败", e);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", e.getMessage());
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取秒杀活动详情
|
* 获取秒杀活动详情
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package com.org.flashsalesystem.controller;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.dto.GroupBuyingDTO;
|
||||||
|
import com.org.flashsalesystem.dto.UserDTO;
|
||||||
|
import com.org.flashsalesystem.service.GroupBuyingService;
|
||||||
|
import com.org.flashsalesystem.service.UserService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(name = "拼团管理", description = "拼团活动创建、参与、团组管理等接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/groupbuying")
|
||||||
|
@Slf4j
|
||||||
|
public class GroupBuyingController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupBuyingService groupBuyingService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
// ========== 用户端 ==========
|
||||||
|
|
||||||
|
@Operation(summary = "获取拼团活动列表")
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ResponseEntity<Map<String, Object>> getList(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestParam(required = false) Integer status) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = groupBuyingService.getGroupBuyingList(page, size, status);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", result);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取拼团活动列表失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取拼团活动详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getDetail(@PathVariable Long id) {
|
||||||
|
try {
|
||||||
|
GroupBuyingDTO detail = groupBuyingService.getGroupBuyingDetail(id);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", detail);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取拼团活动详情失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取活动下的团组列表")
|
||||||
|
@GetMapping("/{id}/groups")
|
||||||
|
public ResponseEntity<Map<String, Object>> getGroups(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = groupBuyingService.getGroupsByActivity(id, page, size);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", result);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取团组列表失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "参与拼团")
|
||||||
|
@PostMapping("/join")
|
||||||
|
public ResponseEntity<Map<String, Object>> joinGroup(
|
||||||
|
@Validated @RequestBody GroupBuyingDTO.JoinGroupDTO joinDTO,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return createUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBuyingDTO.JoinResultDTO result = groupBuyingService.joinGroupBuying(joinDTO, userId);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", result.getSuccess());
|
||||||
|
response.put("message", result.getMessage());
|
||||||
|
response.put("data", result);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("参与拼团失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取团组详情")
|
||||||
|
@GetMapping("/group/{groupId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getGroupDetail(@PathVariable Long groupId) {
|
||||||
|
try {
|
||||||
|
GroupBuyingDTO.GroupInfoDTO detail = groupBuyingService.getGroupDetail(groupId);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", detail);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取团组详情失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "退出团组")
|
||||||
|
@PostMapping("/group/{groupId}/cancel")
|
||||||
|
public ResponseEntity<Map<String, Object>> cancelMembership(
|
||||||
|
@PathVariable Long groupId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return createUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
groupBuyingService.cancelMembership(groupId, userId);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "已退出团组");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("退出团组失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "我的团组")
|
||||||
|
@GetMapping("/my-groups")
|
||||||
|
public ResponseEntity<Map<String, Object>> getMyGroups(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return createUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = groupBuyingService.getMyGroups(userId, page, size);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", result);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取我的团组失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "拼团统计数据")
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<Map<String, Object>> getStatistics(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
GroupBuyingDTO.StatisticsDTO stats = groupBuyingService.getStatistics(userId);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", stats);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取拼团统计失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 管理员端 ==========
|
||||||
|
|
||||||
|
@Operation(summary = "创建拼团活动")
|
||||||
|
@PostMapping("/admin/create")
|
||||||
|
public ResponseEntity<Map<String, Object>> create(
|
||||||
|
@Validated @RequestBody GroupBuyingDTO.CreateDTO createDTO) {
|
||||||
|
try {
|
||||||
|
GroupBuyingDTO result = groupBuyingService.createGroupBuying(createDTO);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "拼团活动创建成功");
|
||||||
|
response.put("data", result);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("创建拼团活动失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新拼团活动")
|
||||||
|
@PutMapping("/admin/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Validated @RequestBody GroupBuyingDTO.UpdateDTO updateDTO) {
|
||||||
|
try {
|
||||||
|
GroupBuyingDTO result = groupBuyingService.updateGroupBuying(id, updateDTO);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "拼团活动更新成功");
|
||||||
|
response.put("data", result);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("更新拼团活动失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除拼团活动")
|
||||||
|
@DeleteMapping("/admin/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||||
|
try {
|
||||||
|
boolean success = groupBuyingService.deleteGroupBuying(id);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", success);
|
||||||
|
response.put("message", success ? "拼团活动删除成功" : "拼团活动删除失败");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("删除拼团活动失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "预热所有拼团活动库存")
|
||||||
|
@PostMapping("/admin/preload-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> preloadAll() {
|
||||||
|
try {
|
||||||
|
groupBuyingService.preloadAllActiveStock();
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "拼团活动库存预热完成");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("预热拼团库存失败", e);
|
||||||
|
return badRequest(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具方法 ==========
|
||||||
|
|
||||||
|
private Long getCurrentUserId(HttpServletRequest request) {
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session == null) return null;
|
||||||
|
String token = (String) session.getAttribute("token");
|
||||||
|
UserDTO user = userService.getUserByToken(token);
|
||||||
|
return user != null ? user.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> createUnauthorizedResponse() {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "用户未登录或登录已过期");
|
||||||
|
return ResponseEntity.status(401).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> badRequest(String message) {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", message);
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.org.flashsalesystem.controller;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.dto.UserDTO;
|
||||||
|
import com.org.flashsalesystem.service.NotificationService;
|
||||||
|
import com.org.flashsalesystem.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/notification")
|
||||||
|
public class NotificationController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private NotificationService notificationService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ResponseEntity<Map<String, Object>> getNotifications(
|
||||||
|
@RequestParam(required = false) String type,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
if (type != null && !type.isEmpty()) {
|
||||||
|
response.put("data", notificationService.getUserNotificationsByType(userId, type));
|
||||||
|
} else {
|
||||||
|
response.put("data", notificationService.getUserNotifications(userId));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/unread-count")
|
||||||
|
public ResponseEntity<Map<String, Object>> getUnreadCount(HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", notificationService.getUnreadCount(userId));
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/read")
|
||||||
|
public ResponseEntity<Map<String, Object>> markAsRead(@PathVariable Long id,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationService.markAsRead(id, userId);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "已标记为已读");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/read-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> markAllAsRead(HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationService.markAllAsRead(userId);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "已全部标记为已读");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/clear")
|
||||||
|
public ResponseEntity<Map<String, Object>> clearAll(HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationService.clearAll(userId);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "已清空所有通知");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getCurrentUserId(HttpServletRequest request) {
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String token = (String) session.getAttribute("token");
|
||||||
|
UserDTO user = userService.getUserByToken(token);
|
||||||
|
return user != null ? user.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> unauthorized() {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "用户未登录或登录已过期");
|
||||||
|
return ResponseEntity.status(401).body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -41,10 +42,52 @@ public class ProductReviewController {
|
|||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
try {
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "评价提交成功");
|
||||||
|
response.put("data", productReviewService.createReview(userId, createDTO));
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/check")
|
||||||
|
public ResponseEntity<Map<String, Object>> checkReview(@RequestParam Long orderId,
|
||||||
|
@RequestParam Long productId) {
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("success", true);
|
response.put("success", true);
|
||||||
response.put("message", "评价提交成功");
|
response.put("data", productReviewService.checkReviewStatus(orderId, productId));
|
||||||
response.put("data", productReviewService.createReview(userId, createDTO));
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/my")
|
||||||
|
public ResponseEntity<Map<String, Object>> getMyReviews(HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", productReviewService.getUserReviews(userId));
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/order/{orderId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getOrderReviews(@PathVariable Long orderId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long userId = getCurrentUserId(request);
|
||||||
|
if (userId == null) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", productReviewService.getOrderReviews(orderId));
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
173
src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java
Normal file
173
src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package com.org.flashsalesystem.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.validation.constraints.DecimalMin;
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class GroupBuyingDTO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private String productName;
|
||||||
|
private String productImageUrl;
|
||||||
|
private BigDecimal productPrice;
|
||||||
|
private BigDecimal groupPrice;
|
||||||
|
private Integer requiredMembers;
|
||||||
|
private Integer durationMinutes;
|
||||||
|
private Integer totalStock;
|
||||||
|
private Integer remainingStock;
|
||||||
|
private Integer maxPerUser;
|
||||||
|
private Integer status;
|
||||||
|
private String statusDescription;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
private Integer activeGroupCount;
|
||||||
|
private BigDecimal discount;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class CreateDTO {
|
||||||
|
@NotNull(message = "商品ID不能为空")
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
@NotNull(message = "拼团价格不能为空")
|
||||||
|
@DecimalMin(value = "0.01", message = "拼团价格必须大于0")
|
||||||
|
private BigDecimal groupPrice;
|
||||||
|
|
||||||
|
@Min(value = 2, message = "成团人数至少为2人")
|
||||||
|
private Integer requiredMembers = 2;
|
||||||
|
|
||||||
|
@Min(value = 1, message = "有效期至少为1分钟")
|
||||||
|
private Integer durationMinutes = 1440;
|
||||||
|
|
||||||
|
@Min(value = 1, message = "总库存至少为1")
|
||||||
|
private Integer totalStock;
|
||||||
|
|
||||||
|
@Min(value = 1, message = "每人限购至少为1")
|
||||||
|
private Integer maxPerUser = 1;
|
||||||
|
|
||||||
|
@NotNull(message = "开始时间不能为空")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
@NotNull(message = "结束时间不能为空")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class UpdateDTO {
|
||||||
|
private Long productId;
|
||||||
|
private BigDecimal groupPrice;
|
||||||
|
private Integer requiredMembers;
|
||||||
|
private Integer durationMinutes;
|
||||||
|
private Integer totalStock;
|
||||||
|
private Integer maxPerUser;
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class JoinGroupDTO {
|
||||||
|
@NotNull(message = "拼团活动ID不能为空")
|
||||||
|
private Long groupBuyingId;
|
||||||
|
|
||||||
|
private Long groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class GroupInfoDTO {
|
||||||
|
private Long id;
|
||||||
|
private String groupNo;
|
||||||
|
private Long groupBuyingId;
|
||||||
|
private Long leaderUserId;
|
||||||
|
private String leaderUsername;
|
||||||
|
private Integer requiredMembers;
|
||||||
|
private Integer currentMembers;
|
||||||
|
private Integer status;
|
||||||
|
private String statusDescription;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime expireTime;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
private List<MemberDTO> members;
|
||||||
|
private GroupBuyingDTO groupBuying;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class MemberDTO {
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String username;
|
||||||
|
private String avatar;
|
||||||
|
private Long orderId;
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime joinedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class JoinResultDTO {
|
||||||
|
private Boolean success;
|
||||||
|
private String message;
|
||||||
|
private Long groupId;
|
||||||
|
private String groupNo;
|
||||||
|
private Long orderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class StatisticsDTO {
|
||||||
|
private Long totalActivities;
|
||||||
|
private Long activeActivities;
|
||||||
|
private Long myGroups;
|
||||||
|
private Long successGroups;
|
||||||
|
private BigDecimal totalSaved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public class OrderDTO {
|
|||||||
private Long userId;
|
private Long userId;
|
||||||
private String username;
|
private String username;
|
||||||
private Long productId;
|
private Long productId;
|
||||||
|
private Long flashSaleId;
|
||||||
private String productName;
|
private String productName;
|
||||||
private String productImageUrl;
|
private String productImageUrl;
|
||||||
private Integer quantity;
|
private Integer quantity;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ public class ProductReviewDTO {
|
|||||||
private Long userId;
|
private Long userId;
|
||||||
private Long orderId;
|
private Long orderId;
|
||||||
private String username;
|
private String username;
|
||||||
|
private String productName;
|
||||||
|
private String productImage;
|
||||||
private Integer rating;
|
private Integer rating;
|
||||||
private String content;
|
private String content;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
@@ -64,4 +66,12 @@ public class ProductReviewDTO {
|
|||||||
private Integer status;
|
private Integer status;
|
||||||
private String adminReply;
|
private String adminReply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class CheckDTO {
|
||||||
|
private boolean reviewed;
|
||||||
|
private ProductReviewDTO review;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.org.flashsalesystem.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "group_buying")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class GroupBuying {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "product_id", nullable = false)
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
@Column(name = "group_price", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal groupPrice;
|
||||||
|
|
||||||
|
@Column(name = "required_members", nullable = false)
|
||||||
|
private Integer requiredMembers = 2;
|
||||||
|
|
||||||
|
@Column(name = "duration_minutes", nullable = false)
|
||||||
|
private Integer durationMinutes = 1440;
|
||||||
|
|
||||||
|
@Column(name = "total_stock", nullable = false)
|
||||||
|
private Integer totalStock;
|
||||||
|
|
||||||
|
@Column(name = "remaining_stock", nullable = false)
|
||||||
|
private Integer remainingStock;
|
||||||
|
|
||||||
|
@Column(name = "max_per_user", nullable = false)
|
||||||
|
private Integer maxPerUser = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-草稿 1-未开始 2-进行中 3-已结束
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer status = 0;
|
||||||
|
|
||||||
|
@Column(name = "start_time", nullable = false)
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
@Column(name = "end_time", nullable = false)
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "product_id", insertable = false, updatable = false)
|
||||||
|
private Product product;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
return now.isAfter(startTime) && now.isBefore(endTime) && status == 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GroupBuyingStatus {
|
||||||
|
DRAFT(0, "草稿"),
|
||||||
|
PENDING(1, "未开始"),
|
||||||
|
ACTIVE(2, "进行中"),
|
||||||
|
ENDED(3, "已结束");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
GroupBuyingStatus(int code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() { return code; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.org.flashsalesystem.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "group_buying_group")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class GroupBuyingGroup {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "group_no", nullable = false, unique = true, length = 64)
|
||||||
|
private String groupNo;
|
||||||
|
|
||||||
|
@Column(name = "group_buying_id", nullable = false)
|
||||||
|
private Long groupBuyingId;
|
||||||
|
|
||||||
|
@Column(name = "leader_user_id", nullable = false)
|
||||||
|
private Long leaderUserId;
|
||||||
|
|
||||||
|
@Column(name = "required_members", nullable = false)
|
||||||
|
private Integer requiredMembers;
|
||||||
|
|
||||||
|
@Column(name = "current_members", nullable = false)
|
||||||
|
private Integer currentMembers = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:1-拼团中 2-已成团 3-已失败(超时)
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer status = 1;
|
||||||
|
|
||||||
|
@Column(name = "expire_time", nullable = false)
|
||||||
|
private LocalDateTime expireTime;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "completed_at")
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "group_buying_id", insertable = false, updatable = false)
|
||||||
|
private GroupBuying groupBuying;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "leader_user_id", insertable = false, updatable = false)
|
||||||
|
private User leader;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GroupStatus {
|
||||||
|
FORMING(1, "拼团中"),
|
||||||
|
SUCCESS(2, "已成团"),
|
||||||
|
FAILED(3, "已失败");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
GroupStatus(int code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() { return code; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.org.flashsalesystem.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "group_buying_member", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"})
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class GroupBuyingMember {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "group_id", nullable = false)
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "order_id")
|
||||||
|
private Long orderId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:1-已加入 2-已成团 3-已退出
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer status = 1;
|
||||||
|
|
||||||
|
@Column(name = "joined_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime joinedAt;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "group_id", insertable = false, updatable = false)
|
||||||
|
private GroupBuyingGroup group;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", insertable = false, updatable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
joinedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.org.flashsalesystem.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "notifications", indexes = {
|
||||||
|
@Index(name = "idx_notification_user_read", columnList = "user_id, is_read"),
|
||||||
|
@Index(name = "idx_notification_user_created", columnList = "user_id, created_at")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Notification {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String link;
|
||||||
|
|
||||||
|
@Column(name = "is_read", nullable = false)
|
||||||
|
private Boolean read = false;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,12 @@ public class Order {
|
|||||||
@Column(name = "product_id", nullable = false)
|
@Column(name = "product_id", nullable = false)
|
||||||
private Long productId;
|
private Long productId;
|
||||||
|
|
||||||
|
@Column(name = "flash_sale_id")
|
||||||
|
private Long flashSaleId;
|
||||||
|
|
||||||
|
@Column(name = "group_buying_group_id")
|
||||||
|
private Long groupBuyingGroupId;
|
||||||
|
|
||||||
@Min(value = 1, message = "商品数量必须大于0")
|
@Min(value = 1, message = "商品数量必须大于0")
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Integer quantity;
|
private Integer quantity;
|
||||||
@@ -145,7 +151,8 @@ public class Order {
|
|||||||
*/
|
*/
|
||||||
public enum OrderType {
|
public enum OrderType {
|
||||||
NORMAL(1, "普通订单"),
|
NORMAL(1, "普通订单"),
|
||||||
FLASH_SALE(2, "秒杀订单");
|
FLASH_SALE(2, "秒杀订单"),
|
||||||
|
GROUP_BUYING(3, "拼团订单");
|
||||||
|
|
||||||
private final int code;
|
private final int code;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 秒杀活动数据访问层
|
* 秒杀活动数据访问层
|
||||||
@@ -19,16 +18,18 @@ import java.util.Optional;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
|
public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据商品ID查找秒杀活动
|
|
||||||
*/
|
|
||||||
Optional<FlashSale> findByProductId(Long productId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分页查找指定商品的秒杀活动
|
* 分页查找指定商品的秒杀活动
|
||||||
*/
|
*/
|
||||||
Page<FlashSale> findByProductId(Long productId, Pageable pageable);
|
Page<FlashSale> findByProductId(Long productId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找指定时间点覆盖的商品秒杀活动
|
||||||
|
*/
|
||||||
|
@Query("SELECT f FROM FlashSale f WHERE f.productId = :productId AND f.startTime <= :targetTime AND f.endTime >= :targetTime")
|
||||||
|
List<FlashSale> findByProductIdAndCoveringTime(@Param("productId") Long productId,
|
||||||
|
@Param("targetTime") LocalDateTime targetTime);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据商品ID和状态查找秒杀活动
|
* 根据商品ID和状态查找秒杀活动
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +79,13 @@ public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
|
|||||||
" >= :quantity")
|
" >= :quantity")
|
||||||
int updateFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
|
int updateFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复秒杀库存(订单取消时使用)
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE FlashSale f SET f.flashStock = f.flashStock + :quantity WHERE f.id = :flashSaleId")
|
||||||
|
int increaseFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新秒杀活动状态
|
* 更新秒杀活动状态
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.org.flashsalesystem.repository;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.entity.GroupBuyingGroup;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GroupBuyingGroupRepository extends JpaRepository<GroupBuyingGroup, Long> {
|
||||||
|
|
||||||
|
List<GroupBuyingGroup> findByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status);
|
||||||
|
|
||||||
|
Optional<GroupBuyingGroup> findByGroupNo(String groupNo);
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GroupBuyingGroup g WHERE g.status = 1 AND g.expireTime < :now")
|
||||||
|
List<GroupBuyingGroup> findExpiredGroups(@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
List<GroupBuyingGroup> findByLeaderUserId(Long userId);
|
||||||
|
|
||||||
|
Page<GroupBuyingGroup> findByGroupBuyingId(Long groupBuyingId, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GroupBuyingGroup g WHERE g.id IN " +
|
||||||
|
"(SELECT m.groupId FROM GroupBuyingMember m WHERE m.userId = :userId AND m.status != 3)")
|
||||||
|
Page<GroupBuyingGroup> findByMemberUserId(@Param("userId") Long userId, Pageable pageable);
|
||||||
|
|
||||||
|
long countByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers + 1 WHERE g.id = :id")
|
||||||
|
int incrementCurrentMembers(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers - 1 WHERE g.id = :id AND g.currentMembers > 0")
|
||||||
|
int decrementCurrentMembers(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuyingGroup g SET g.status = :status, g.completedAt = :completedAt WHERE g.id = :id")
|
||||||
|
int updateStatusAndCompletedAt(@Param("id") Long id, @Param("status") Integer status, @Param("completedAt") LocalDateTime completedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.org.flashsalesystem.repository;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.entity.GroupBuyingMember;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GroupBuyingMemberRepository extends JpaRepository<GroupBuyingMember, Long> {
|
||||||
|
|
||||||
|
List<GroupBuyingMember> findByGroupId(Long groupId);
|
||||||
|
|
||||||
|
List<GroupBuyingMember> findByGroupIdAndStatus(Long groupId, Integer status);
|
||||||
|
|
||||||
|
boolean existsByGroupIdAndUserIdAndStatusNot(Long groupId, Long userId, Integer excludeStatus);
|
||||||
|
|
||||||
|
Optional<GroupBuyingMember> findByGroupIdAndUserId(Long groupId, Long userId);
|
||||||
|
|
||||||
|
long countByGroupIdAndStatusNot(Long groupId, Integer excludeStatus);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuyingMember m SET m.status = :status WHERE m.groupId = :groupId AND m.status = 1")
|
||||||
|
int updateStatusByGroupId(@Param("groupId") Long groupId, @Param("status") Integer status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.org.flashsalesystem.repository;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.entity.GroupBuying;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GroupBuyingRepository extends JpaRepository<GroupBuying, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2")
|
||||||
|
List<GroupBuying> findActiveGroupBuyings(@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2")
|
||||||
|
Page<GroupBuying> findActiveGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GroupBuying g WHERE g.startTime > :now AND g.status = 1")
|
||||||
|
List<GroupBuying> findUpcomingGroupBuyings(@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GroupBuying g WHERE g.endTime <= :now OR g.status = 3")
|
||||||
|
Page<GroupBuying> findEndedGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable);
|
||||||
|
|
||||||
|
Page<GroupBuying> findByStatus(Integer status, Pageable pageable);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock - :quantity WHERE g.id = :id AND g.remainingStock >= :quantity")
|
||||||
|
int updateStock(@Param("id") Long id, @Param("quantity") Integer quantity);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock + :quantity WHERE g.id = :id")
|
||||||
|
int increaseStock(@Param("id") Long id, @Param("quantity") Integer quantity);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE GroupBuying g SET g.status = :status WHERE g.id = :id")
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") Integer status);
|
||||||
|
|
||||||
|
long countByStatus(Integer status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.org.flashsalesystem.repository;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.entity.Notification;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface NotificationRepository extends JpaRepository<Notification, Long> {
|
||||||
|
|
||||||
|
List<Notification> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
|
|
||||||
|
List<Notification> findByUserIdAndTypeOrderByCreatedAtDesc(Long userId, String type);
|
||||||
|
|
||||||
|
long countByUserIdAndReadFalse(Long userId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Notification n SET n.read = true WHERE n.userId = :userId AND n.read = false")
|
||||||
|
int markAllAsRead(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Notification n SET n.read = true WHERE n.id = :id AND n.userId = :userId")
|
||||||
|
int markAsRead(@Param("id") Long id, @Param("userId") Long userId);
|
||||||
|
|
||||||
|
void deleteByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -49,6 +49,23 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
|
|||||||
*/
|
*/
|
||||||
Page<Order> findByOrderType(Integer orderType, Pageable pageable);
|
Page<Order> findByOrderType(Integer orderType, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查找用户指定类型的订单
|
||||||
|
*/
|
||||||
|
Page<Order> findByUserIdAndOrderType(Long userId, Integer orderType, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户指定类型的订单数量
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(o) FROM Order o WHERE o.userId = :userId AND o.orderType = :orderType")
|
||||||
|
Long countByUserIdAndOrderType(@Param("userId") Long userId, @Param("orderType") Integer orderType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户指定类型且非取消的订单数量(抢购成功)
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(o) FROM Order o WHERE o.userId = :userId AND o.orderType = :orderType AND o.status != 5")
|
||||||
|
Long countByUserIdAndOrderTypeAndStatusNot5(@Param("userId") Long userId, @Param("orderType") Integer orderType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查找秒杀订单
|
* 查找秒杀订单
|
||||||
*/
|
*/
|
||||||
@@ -116,10 +133,14 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
|
|||||||
List<Order> findFlashSaleOrdersByUserId(@Param("userId") Long userId);
|
List<Order> findFlashSaleOrdersByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查用户是否已经购买过指定商品的秒杀
|
* 检查用户是否已经参与过指定秒杀活动
|
||||||
*/
|
*/
|
||||||
@Query("SELECT COUNT(o) > 0 FROM Order o WHERE o.userId = :userId AND o.productId = :productId AND o.orderType = 2")
|
boolean existsByUserIdAndFlashSaleIdAndOrderType(Long userId, Long flashSaleId, Integer orderType);
|
||||||
boolean existsFlashSaleOrder(@Param("userId") Long userId, @Param("productId") Long productId);
|
|
||||||
|
/**
|
||||||
|
* 检查指定秒杀活动是否已有订单
|
||||||
|
*/
|
||||||
|
boolean existsByFlashSaleIdAndOrderType(Long flashSaleId, Integer orderType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据创建时间范围统计订单数量
|
* 根据创建时间范围统计订单数量
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ public interface ProductReviewRepository extends JpaRepository<ProductReview, Lo
|
|||||||
|
|
||||||
long countByProductId(Long productId);
|
long countByProductId(Long productId);
|
||||||
|
|
||||||
|
long countByProductIdAndStatus(Long productId, Integer status);
|
||||||
|
|
||||||
@Query("SELECT AVG(r.rating) FROM ProductReview r WHERE r.productId = :productId")
|
@Query("SELECT AVG(r.rating) FROM ProductReview r WHERE r.productId = :productId")
|
||||||
Double findAverageRatingByProductId(@Param("productId") Long productId);
|
Double findAverageRatingByProductId(@Param("productId") Long productId);
|
||||||
|
|
||||||
|
List<ProductReview> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
|
|
||||||
|
List<ProductReview> findByOrderId(Long orderId);
|
||||||
|
|
||||||
|
boolean existsByOrderIdAndProductId(Long orderId, Long productId);
|
||||||
|
|
||||||
|
Optional<ProductReview> findByOrderIdAndProductId(Long orderId, Long productId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -318,6 +320,7 @@ public class CartService {
|
|||||||
/**
|
/**
|
||||||
* 购物车下单
|
* 购物车下单
|
||||||
*/
|
*/
|
||||||
|
@Transactional
|
||||||
public OrderDTO checkoutCart(Long userId, List<Long> productIds) {
|
public OrderDTO checkoutCart(Long userId, List<Long> productIds) {
|
||||||
log.info("购物车下单: 用户ID={}, 商品IDs={}", userId, productIds);
|
log.info("购物车下单: 用户ID={}, 商品IDs={}", userId, productIds);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.data.domain.PageRequest;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -90,10 +91,9 @@ public class FlashSaleService {
|
|||||||
throw new RuntimeException("开始时间不能早于当前时间");
|
throw new RuntimeException("开始时间不能早于当前时间");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已有该商品的秒杀活动
|
// 验证秒杀价格必须小于商品原价
|
||||||
Optional<FlashSale> existingFlashSale = flashSaleRepository.findByProductId(createDTO.getProductId());
|
if (createDTO.getFlashPrice().compareTo(product.getPrice()) >= 0) {
|
||||||
if (existingFlashSale.isPresent()) {
|
throw new RuntimeException("秒杀价格必须小于商品原价");
|
||||||
throw new RuntimeException("该商品已有秒杀活动");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建秒杀活动
|
// 创建秒杀活动
|
||||||
@@ -157,8 +157,8 @@ public class FlashSaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查数据库中是否已有订单
|
// 检查数据库中是否已有订单
|
||||||
if (orderRepository.existsFlashSaleOrder(userId, flashSale.getProductId())) {
|
if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) {
|
||||||
return createFailResult("您已经购买过该商品");
|
return createFailResult("您已经参与过该秒杀活动");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查购买数量限制
|
// 检查购买数量限制
|
||||||
@@ -174,6 +174,14 @@ public class FlashSaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 二次校验:锁内重新检查用户是否已参与(防止并发竞态)
|
||||||
|
if (redisService.sIsMember(successUsersKey, userId)) {
|
||||||
|
return createFailResult("您已经参与过该秒杀活动");
|
||||||
|
}
|
||||||
|
if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) {
|
||||||
|
return createFailResult("您已经参与过该秒杀活动");
|
||||||
|
}
|
||||||
|
|
||||||
// 检查并修复库存数据
|
// 检查并修复库存数据
|
||||||
String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId();
|
String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId();
|
||||||
String currentStock = redisService.getString(stockKey);
|
String currentStock = redisService.getString(stockKey);
|
||||||
@@ -300,9 +308,11 @@ public class FlashSaleService {
|
|||||||
// 验证排序字段
|
// 验证排序字段
|
||||||
String sortBy = validateSortField(queryDTO.getSortBy());
|
String sortBy = validateSortField(queryDTO.getSortBy());
|
||||||
|
|
||||||
|
// 限制分页大小
|
||||||
|
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||||
// 构建分页和排序
|
// 构建分页和排序
|
||||||
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), sortBy);
|
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), sortBy);
|
||||||
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
|
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
|
||||||
|
|
||||||
Page<FlashSale> flashSalePage;
|
Page<FlashSale> flashSalePage;
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -621,6 +631,55 @@ public class FlashSaleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复秒杀库存(订单取消时调用)
|
||||||
|
* 恢复Redis库存、DB库存,并移除成功用户集合记录
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void restoreFlashSaleStock(Long flashSaleId, Long productId, LocalDateTime orderCreatedAt, Long userId,
|
||||||
|
Integer quantity) {
|
||||||
|
log.info("恢复秒杀库存: flashSaleId={}, productId={}, orderCreatedAt={}, userId={}, quantity={}",
|
||||||
|
flashSaleId, productId, orderCreatedAt, userId, quantity);
|
||||||
|
|
||||||
|
Optional<FlashSale> flashSaleOpt = Optional.empty();
|
||||||
|
if (flashSaleId == null) {
|
||||||
|
List<FlashSale> matchedFlashSales = flashSaleRepository.findByProductIdAndCoveringTime(productId,
|
||||||
|
orderCreatedAt);
|
||||||
|
if (matchedFlashSales.size() == 1) {
|
||||||
|
flashSaleOpt = Optional.of(matchedFlashSales.get(0));
|
||||||
|
log.info("根据商品和下单时间回填秒杀活动: flashSaleId={}, productId={}",
|
||||||
|
flashSaleOpt.get().getId(), productId);
|
||||||
|
} else {
|
||||||
|
log.warn("订单未记录秒杀活动ID且无法唯一匹配历史活动,跳过秒杀库存恢复: productId={}, matches={}",
|
||||||
|
productId, matchedFlashSales.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flashSaleOpt = flashSaleRepository.findById(flashSaleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flashSaleOpt.isPresent()) {
|
||||||
|
log.warn("未找到对应的秒杀活动,跳过秒杀库存恢复: flashSaleId={}", flashSaleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlashSale flashSale = flashSaleOpt.get();
|
||||||
|
|
||||||
|
// 恢复Redis库存
|
||||||
|
String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId();
|
||||||
|
redisService.incrBy(stockKey, quantity);
|
||||||
|
|
||||||
|
// 恢复DB库存
|
||||||
|
flashSaleRepository.increaseFlashStock(flashSale.getId(), quantity);
|
||||||
|
|
||||||
|
// 移除成功用户集合记录
|
||||||
|
String successUsersKey = FLASH_SALE_SUCCESS_USERS_PREFIX + flashSale.getId();
|
||||||
|
redisService.sRem(successUsersKey, userId);
|
||||||
|
|
||||||
|
log.info("秒杀库存恢复成功: flashSaleId={}, userId={}, quantity={}",
|
||||||
|
flashSale.getId(), userId, quantity);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取秒杀活动剩余库存
|
* 获取秒杀活动剩余库存
|
||||||
*/
|
*/
|
||||||
@@ -712,7 +771,7 @@ public class FlashSaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有相关订单
|
// 检查是否有相关订单
|
||||||
if (orderRepository.existsFlashSaleOrder(null, flashSale.getProductId())) {
|
if (orderRepository.existsByFlashSaleIdAndOrderType(flashSaleId, 2)) {
|
||||||
throw new RuntimeException("该秒杀活动已有订单,无法删除");
|
throw new RuntimeException("该秒杀活动已有订单,无法删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,6 +945,60 @@ public class FlashSaleService {
|
|||||||
return buildFlashSaleDTO(flashSale, product);
|
return buildFlashSaleDTO(flashSale, product);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取秒杀活动统计信息(即将开始、正在进行的全局数量 + 用户参与/成功数量)
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getFlashSaleStatistics(Long userId) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
long upcoming = flashSaleRepository.findUpcomingFlashSales(now).size();
|
||||||
|
long active = flashSaleRepository.findActiveFlashSales(now).size();
|
||||||
|
|
||||||
|
long participated = 0;
|
||||||
|
long success = 0;
|
||||||
|
if (userId != null) {
|
||||||
|
participated = orderRepository.countByUserIdAndOrderType(userId, 2);
|
||||||
|
success = orderRepository.countByUserIdAndOrderTypeAndStatusNot5(userId, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
stats.put("upcoming", upcoming);
|
||||||
|
stats.put("active", active);
|
||||||
|
stats.put("participated", participated);
|
||||||
|
stats.put("success", success);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时预热即将开始的秒杀活动库存(每5分钟执行一次)
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 300000)
|
||||||
|
public void scheduledPreloadFlashSales() {
|
||||||
|
log.info("定时任务:检查即将开始的秒杀活动并预热库存");
|
||||||
|
try {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime threshold = now.plusMinutes(30);
|
||||||
|
List<FlashSale> upcomingFlashSales = flashSaleRepository.findUpcomingFlashSales(now);
|
||||||
|
|
||||||
|
int preloadCount = 0;
|
||||||
|
for (FlashSale flashSale : upcomingFlashSales) {
|
||||||
|
if (flashSale.getStartTime().isBefore(threshold)) {
|
||||||
|
preloadFlashSale(flashSale.getId());
|
||||||
|
preloadCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preloadCount > 0) {
|
||||||
|
log.info("定时预热完成:预热了{}个即将开始的秒杀活动", preloadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时更新秒杀活动状态
|
||||||
|
updateFlashSaleStatus();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("定时预热秒杀活动失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新秒杀活动状态
|
* 更新秒杀活动状态
|
||||||
*/
|
*/
|
||||||
@@ -1051,6 +1164,7 @@ public class FlashSaleService {
|
|||||||
order.setOrderNo("FS" + System.currentTimeMillis() + String.format("%03d", new java.util.Random().nextInt(1000)));
|
order.setOrderNo("FS" + System.currentTimeMillis() + String.format("%03d", new java.util.Random().nextInt(1000)));
|
||||||
order.setUserId(userId);
|
order.setUserId(userId);
|
||||||
order.setProductId(flashSale.getProductId());
|
order.setProductId(flashSale.getProductId());
|
||||||
|
order.setFlashSaleId(flashSale.getId());
|
||||||
order.setQuantity(participateDTO.getQuantity());
|
order.setQuantity(participateDTO.getQuantity());
|
||||||
order.setTotalPrice(flashSale.getFlashPrice().multiply(BigDecimal.valueOf(participateDTO.getQuantity())));
|
order.setTotalPrice(flashSale.getFlashPrice().multiply(BigDecimal.valueOf(participateDTO.getQuantity())));
|
||||||
order.setStatus(1); // 待支付
|
order.setStatus(1); // 待支付
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
package com.org.flashsalesystem.service;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.dto.GroupBuyingDTO;
|
||||||
|
import com.org.flashsalesystem.dto.ProductDTO;
|
||||||
|
import com.org.flashsalesystem.dto.UserDTO;
|
||||||
|
import com.org.flashsalesystem.entity.*;
|
||||||
|
import com.org.flashsalesystem.repository.*;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class GroupBuyingService {
|
||||||
|
|
||||||
|
private static final String GB_STOCK_PREFIX = "groupbuying_stock:";
|
||||||
|
private static final String GB_LOCK_PREFIX = "groupbuying_lock:";
|
||||||
|
private static final String GB_MEMBERS_PREFIX = "groupbuying_members:";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupBuyingRepository groupBuyingRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupBuyingGroupRepository groupBuyingGroupRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupBuyingMemberRepository groupBuyingMemberRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OrderRepository orderRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ProductRepository productRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ProductService productService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisService redisService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedissonLockService redissonLockService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("customStringRedisTemplate")
|
||||||
|
private RedisTemplate<String, String> stringRedisTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DefaultRedisScript<Long> groupBuyingStockScript;
|
||||||
|
|
||||||
|
// ========== 管理员操作 ==========
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GroupBuyingDTO createGroupBuying(GroupBuyingDTO.CreateDTO createDTO) {
|
||||||
|
log.info("创建拼团活动: productId={}", createDTO.getProductId());
|
||||||
|
|
||||||
|
Product product = productRepository.findById(createDTO.getProductId())
|
||||||
|
.orElseThrow(() -> new RuntimeException("商品不存在"));
|
||||||
|
|
||||||
|
if (createDTO.getGroupPrice().compareTo(product.getPrice()) >= 0) {
|
||||||
|
throw new RuntimeException("拼团价格必须低于商品原价");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createDTO.getEndTime().isBefore(createDTO.getStartTime())) {
|
||||||
|
throw new RuntimeException("结束时间不能早于开始时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBuying gb = new GroupBuying();
|
||||||
|
gb.setProductId(createDTO.getProductId());
|
||||||
|
gb.setGroupPrice(createDTO.getGroupPrice());
|
||||||
|
gb.setRequiredMembers(createDTO.getRequiredMembers());
|
||||||
|
gb.setDurationMinutes(createDTO.getDurationMinutes());
|
||||||
|
gb.setTotalStock(createDTO.getTotalStock());
|
||||||
|
gb.setRemainingStock(createDTO.getTotalStock());
|
||||||
|
gb.setMaxPerUser(createDTO.getMaxPerUser());
|
||||||
|
gb.setStatus(0); // 草稿
|
||||||
|
gb.setStartTime(createDTO.getStartTime());
|
||||||
|
gb.setEndTime(createDTO.getEndTime());
|
||||||
|
|
||||||
|
gb = groupBuyingRepository.save(gb);
|
||||||
|
log.info("拼团活动创建成功: id={}", gb.getId());
|
||||||
|
|
||||||
|
return buildDTO(gb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GroupBuyingDTO updateGroupBuying(Long id, GroupBuyingDTO.UpdateDTO updateDTO) {
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||||
|
|
||||||
|
if (gb.getStatus() == 2) {
|
||||||
|
throw new RuntimeException("进行中的活动不能修改");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateDTO.getProductId() != null) gb.setProductId(updateDTO.getProductId());
|
||||||
|
if (updateDTO.getGroupPrice() != null) gb.setGroupPrice(updateDTO.getGroupPrice());
|
||||||
|
if (updateDTO.getRequiredMembers() != null) gb.setRequiredMembers(updateDTO.getRequiredMembers());
|
||||||
|
if (updateDTO.getDurationMinutes() != null) gb.setDurationMinutes(updateDTO.getDurationMinutes());
|
||||||
|
if (updateDTO.getTotalStock() != null) {
|
||||||
|
int diff = updateDTO.getTotalStock() - gb.getTotalStock();
|
||||||
|
gb.setTotalStock(updateDTO.getTotalStock());
|
||||||
|
gb.setRemainingStock(gb.getRemainingStock() + diff);
|
||||||
|
}
|
||||||
|
if (updateDTO.getMaxPerUser() != null) gb.setMaxPerUser(updateDTO.getMaxPerUser());
|
||||||
|
if (updateDTO.getStatus() != null) gb.setStatus(updateDTO.getStatus());
|
||||||
|
if (updateDTO.getStartTime() != null) gb.setStartTime(updateDTO.getStartTime());
|
||||||
|
if (updateDTO.getEndTime() != null) gb.setEndTime(updateDTO.getEndTime());
|
||||||
|
|
||||||
|
gb = groupBuyingRepository.save(gb);
|
||||||
|
return buildDTO(gb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean deleteGroupBuying(Long id) {
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||||
|
|
||||||
|
if (gb.getStatus() == 2) {
|
||||||
|
throw new RuntimeException("进行中的活动不能删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
groupBuyingRepository.deleteById(id);
|
||||||
|
redisService.delete(GB_STOCK_PREFIX + id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 查询操作 ==========
|
||||||
|
|
||||||
|
public Map<String, Object> getGroupBuyingList(int page, int size, Integer status) {
|
||||||
|
int pageSize = Math.min(size, 100);
|
||||||
|
Pageable pageable = PageRequest.of(page, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
|
||||||
|
Page<GroupBuying> gbPage;
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
if (status != null) {
|
||||||
|
if (status == 2) {
|
||||||
|
gbPage = groupBuyingRepository.findActiveGroupBuyings(now, pageable);
|
||||||
|
} else if (status == 3) {
|
||||||
|
gbPage = groupBuyingRepository.findEndedGroupBuyings(now, pageable);
|
||||||
|
} else {
|
||||||
|
gbPage = groupBuyingRepository.findByStatus(status, pageable);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
gbPage = groupBuyingRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupBuyingDTO> dtos = gbPage.getContent().stream()
|
||||||
|
.map(this::buildDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("content", dtos);
|
||||||
|
result.put("totalElements", gbPage.getTotalElements());
|
||||||
|
result.put("totalPages", gbPage.getTotalPages());
|
||||||
|
result.put("currentPage", gbPage.getNumber());
|
||||||
|
result.put("size", gbPage.getSize());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBuyingDTO getGroupBuyingDetail(Long id) {
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||||
|
return buildDTO(gb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getGroupsByActivity(Long groupBuyingId, int page, int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
Page<GroupBuyingGroup> groupPage = groupBuyingGroupRepository.findByGroupBuyingId(groupBuyingId, pageable);
|
||||||
|
|
||||||
|
List<GroupBuyingDTO.GroupInfoDTO> dtos = groupPage.getContent().stream()
|
||||||
|
.map(this::buildGroupInfoDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("content", dtos);
|
||||||
|
result.put("totalElements", groupPage.getTotalElements());
|
||||||
|
result.put("totalPages", groupPage.getTotalPages());
|
||||||
|
result.put("currentPage", groupPage.getNumber());
|
||||||
|
result.put("size", groupPage.getSize());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBuyingDTO.GroupInfoDTO getGroupDetail(Long groupId) {
|
||||||
|
GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("团组不存在"));
|
||||||
|
return buildGroupInfoDTO(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getMyGroups(Long userId, int page, int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
Page<GroupBuyingGroup> groupPage = groupBuyingGroupRepository.findByMemberUserId(userId, pageable);
|
||||||
|
|
||||||
|
List<GroupBuyingDTO.GroupInfoDTO> dtos = groupPage.getContent().stream()
|
||||||
|
.map(this::buildGroupInfoDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("content", dtos);
|
||||||
|
result.put("totalElements", groupPage.getTotalElements());
|
||||||
|
result.put("totalPages", groupPage.getTotalPages());
|
||||||
|
result.put("currentPage", groupPage.getNumber());
|
||||||
|
result.put("size", groupPage.getSize());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupBuyingDTO.StatisticsDTO getStatistics(Long userId) {
|
||||||
|
GroupBuyingDTO.StatisticsDTO stats = new GroupBuyingDTO.StatisticsDTO();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
stats.setTotalActivities(groupBuyingRepository.count());
|
||||||
|
stats.setActiveActivities(groupBuyingRepository.countByStatus(2));
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
Page<GroupBuyingGroup> myGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1));
|
||||||
|
stats.setMyGroups(myGroups.getTotalElements());
|
||||||
|
|
||||||
|
// Count successful groups where user participated
|
||||||
|
long successCount = 0;
|
||||||
|
BigDecimal totalSaved = BigDecimal.ZERO;
|
||||||
|
Page<GroupBuyingGroup> allMyGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1000));
|
||||||
|
for (GroupBuyingGroup g : allMyGroups.getContent()) {
|
||||||
|
if (g.getStatus() == 2) {
|
||||||
|
successCount++;
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(g.getGroupBuyingId()).orElse(null);
|
||||||
|
if (gb != null) {
|
||||||
|
Product product = productRepository.findById(gb.getProductId()).orElse(null);
|
||||||
|
if (product != null) {
|
||||||
|
totalSaved = totalSaved.add(product.getPrice().subtract(gb.getGroupPrice()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.setSuccessGroups(successCount);
|
||||||
|
stats.setTotalSaved(totalSaved);
|
||||||
|
} else {
|
||||||
|
stats.setMyGroups(0L);
|
||||||
|
stats.setSuccessGroups(0L);
|
||||||
|
stats.setTotalSaved(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 核心拼团逻辑 ==========
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GroupBuyingDTO.JoinResultDTO joinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) {
|
||||||
|
Long activityId = joinDTO.getGroupBuyingId();
|
||||||
|
String lockKey = GB_LOCK_PREFIX + activityId;
|
||||||
|
|
||||||
|
if (!redissonLockService.tryLock(lockKey, 5, 30)) {
|
||||||
|
throw new RuntimeException("系统繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return doJoinGroupBuying(joinDTO, userId);
|
||||||
|
} finally {
|
||||||
|
redissonLockService.unlock(lockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBuyingDTO.JoinResultDTO doJoinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) {
|
||||||
|
Long activityId = joinDTO.getGroupBuyingId();
|
||||||
|
|
||||||
|
// 1. 校验活动状态
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(activityId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||||
|
|
||||||
|
if (!gb.isActive()) {
|
||||||
|
throw new RuntimeException("拼团活动未在进行中");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gb.getRemainingStock() <= 0) {
|
||||||
|
throw new RuntimeException("库存不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取商品信息
|
||||||
|
Product product = productRepository.findById(gb.getProductId())
|
||||||
|
.orElseThrow(() -> new RuntimeException("商品不存在"));
|
||||||
|
|
||||||
|
GroupBuyingGroup group;
|
||||||
|
|
||||||
|
if (joinDTO.getGroupId() != null) {
|
||||||
|
// 加入现有团组
|
||||||
|
group = groupBuyingGroupRepository.findById(joinDTO.getGroupId())
|
||||||
|
.orElseThrow(() -> new RuntimeException("团组不存在"));
|
||||||
|
|
||||||
|
if (group.getStatus() != 1) {
|
||||||
|
throw new RuntimeException("该团组已成团或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LocalDateTime.now().isAfter(group.getExpireTime())) {
|
||||||
|
throw new RuntimeException("该团组已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.getCurrentMembers() >= group.getRequiredMembers()) {
|
||||||
|
throw new RuntimeException("该团组已满员");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已经在该团组中
|
||||||
|
if (groupBuyingMemberRepository.existsByGroupIdAndUserIdAndStatusNot(group.getId(), userId, 3)) {
|
||||||
|
throw new RuntimeException("您已在该团组中");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建新团组
|
||||||
|
group = new GroupBuyingGroup();
|
||||||
|
group.setGroupNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)));
|
||||||
|
group.setGroupBuyingId(activityId);
|
||||||
|
group.setLeaderUserId(userId);
|
||||||
|
group.setRequiredMembers(gb.getRequiredMembers());
|
||||||
|
group.setCurrentMembers(0); // will be incremented below
|
||||||
|
group.setStatus(1);
|
||||||
|
group.setExpireTime(LocalDateTime.now().plusMinutes(gb.getDurationMinutes()));
|
||||||
|
group = groupBuyingGroupRepository.save(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Redis 原子扣库存
|
||||||
|
String stockKey = GB_STOCK_PREFIX + activityId;
|
||||||
|
Long result = stringRedisTemplate.execute(groupBuyingStockScript,
|
||||||
|
Collections.singletonList(stockKey), "1");
|
||||||
|
|
||||||
|
if (result == null || result < 0) {
|
||||||
|
// Redis stock exhausted, double check DB
|
||||||
|
if (gb.getRemainingStock() <= 0) {
|
||||||
|
throw new RuntimeException("库存不足");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. DB 扣库存
|
||||||
|
int updated = groupBuyingRepository.updateStock(activityId, 1);
|
||||||
|
if (updated == 0) {
|
||||||
|
// Restore Redis stock
|
||||||
|
redisService.incrBy(stockKey, 1);
|
||||||
|
throw new RuntimeException("库存不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建订单
|
||||||
|
Order order = new Order();
|
||||||
|
order.setOrderNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)));
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setProductId(gb.getProductId());
|
||||||
|
order.setQuantity(1);
|
||||||
|
order.setTotalPrice(gb.getGroupPrice());
|
||||||
|
order.setStatus(1); // 待支付
|
||||||
|
order.setOrderType(3); // 拼团订单
|
||||||
|
order.setGroupBuyingGroupId(group.getId());
|
||||||
|
order = orderRepository.save(order);
|
||||||
|
|
||||||
|
// 6. 创建成员记录
|
||||||
|
GroupBuyingMember member = new GroupBuyingMember();
|
||||||
|
member.setGroupId(group.getId());
|
||||||
|
member.setUserId(userId);
|
||||||
|
member.setOrderId(order.getId());
|
||||||
|
member.setStatus(1);
|
||||||
|
groupBuyingMemberRepository.save(member);
|
||||||
|
|
||||||
|
// 7. 更新团组人数
|
||||||
|
groupBuyingGroupRepository.incrementCurrentMembers(group.getId());
|
||||||
|
|
||||||
|
// Refresh group from DB
|
||||||
|
group = groupBuyingGroupRepository.findById(group.getId()).orElse(group);
|
||||||
|
|
||||||
|
// 8. 检查是否满员 → 成团
|
||||||
|
if (group.getCurrentMembers() >= group.getRequiredMembers()) {
|
||||||
|
groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 2, LocalDateTime.now());
|
||||||
|
// Update all members status to SUCCESS
|
||||||
|
groupBuyingMemberRepository.updateStatusByGroupId(group.getId(), 2);
|
||||||
|
log.info("拼团成功: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to Redis members set
|
||||||
|
redisService.sAdd(GB_MEMBERS_PREFIX + group.getId(), userId.toString());
|
||||||
|
redisService.expire(GB_MEMBERS_PREFIX + group.getId(), 7, TimeUnit.DAYS);
|
||||||
|
|
||||||
|
GroupBuyingDTO.JoinResultDTO resultDTO = new GroupBuyingDTO.JoinResultDTO();
|
||||||
|
resultDTO.setSuccess(true);
|
||||||
|
resultDTO.setMessage(joinDTO.getGroupId() != null ? "加入拼团成功" : "开团成功");
|
||||||
|
resultDTO.setGroupId(group.getId());
|
||||||
|
resultDTO.setGroupNo(group.getGroupNo());
|
||||||
|
resultDTO.setOrderId(order.getId());
|
||||||
|
|
||||||
|
log.info("用户{}参与拼团成功: activityId={}, groupId={}, orderId={}", userId, activityId, group.getId(), order.getId());
|
||||||
|
return resultDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void cancelMembership(Long groupId, Long userId) {
|
||||||
|
log.info("退出拼团: groupId={}, userId={}", groupId, userId);
|
||||||
|
|
||||||
|
GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("团组不存在"));
|
||||||
|
|
||||||
|
if (group.getStatus() != 1) {
|
||||||
|
throw new RuntimeException("已成团或已失败的团组不能退出");
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBuyingMember member = groupBuyingMemberRepository.findByGroupIdAndUserId(groupId, userId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("您不在该团组中"));
|
||||||
|
|
||||||
|
if (member.getStatus() == 3) {
|
||||||
|
throw new RuntimeException("您已退出该团组");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成员状态
|
||||||
|
member.setStatus(3);
|
||||||
|
groupBuyingMemberRepository.save(member);
|
||||||
|
|
||||||
|
// 更新团组人数
|
||||||
|
groupBuyingGroupRepository.decrementCurrentMembers(groupId);
|
||||||
|
|
||||||
|
// 恢复库存
|
||||||
|
Long activityId = group.getGroupBuyingId();
|
||||||
|
groupBuyingRepository.increaseStock(activityId, 1);
|
||||||
|
redisService.incrBy(GB_STOCK_PREFIX + activityId, 1);
|
||||||
|
|
||||||
|
// 取消订单
|
||||||
|
if (member.getOrderId() != null) {
|
||||||
|
Optional<Order> orderOpt = orderRepository.findById(member.getOrderId());
|
||||||
|
if (orderOpt.isPresent()) {
|
||||||
|
Order order = orderOpt.get();
|
||||||
|
if (order.getStatus() == 1) {
|
||||||
|
order.setStatus(5);
|
||||||
|
order.setRemark("退出拼团,订单取消");
|
||||||
|
orderRepository.save(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from Redis set
|
||||||
|
redisService.sRem(GB_MEMBERS_PREFIX + groupId, userId.toString());
|
||||||
|
|
||||||
|
log.info("退出拼团成功: groupId={}, userId={}", groupId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 定时任务 ==========
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 60000) // 每分钟检查
|
||||||
|
@Transactional
|
||||||
|
public void scheduledCheckExpiredGroups() {
|
||||||
|
List<GroupBuyingGroup> expiredGroups = groupBuyingGroupRepository.findExpiredGroups(LocalDateTime.now());
|
||||||
|
for (GroupBuyingGroup group : expiredGroups) {
|
||||||
|
try {
|
||||||
|
handleExpiredGroup(group);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理超时团组失败: groupId={}", group.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void handleExpiredGroup(GroupBuyingGroup group) {
|
||||||
|
log.info("处理超时团组: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
|
||||||
|
|
||||||
|
// Mark group as FAILED
|
||||||
|
groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 3, LocalDateTime.now());
|
||||||
|
|
||||||
|
// Get active members
|
||||||
|
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupIdAndStatus(group.getId(), 1);
|
||||||
|
|
||||||
|
for (GroupBuyingMember member : members) {
|
||||||
|
// Update member status
|
||||||
|
member.setStatus(3);
|
||||||
|
groupBuyingMemberRepository.save(member);
|
||||||
|
|
||||||
|
// Restore stock
|
||||||
|
groupBuyingRepository.increaseStock(group.getGroupBuyingId(), 1);
|
||||||
|
redisService.incrBy(GB_STOCK_PREFIX + group.getGroupBuyingId(), 1);
|
||||||
|
|
||||||
|
// Cancel order
|
||||||
|
if (member.getOrderId() != null) {
|
||||||
|
Optional<Order> orderOpt = orderRepository.findById(member.getOrderId());
|
||||||
|
if (orderOpt.isPresent()) {
|
||||||
|
Order order = orderOpt.get();
|
||||||
|
if (order.getStatus() == 1) {
|
||||||
|
order.setStatus(5);
|
||||||
|
order.setRemark("拼团超时,订单自动取消");
|
||||||
|
orderRepository.save(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean Redis
|
||||||
|
redisService.delete(GB_MEMBERS_PREFIX + group.getId());
|
||||||
|
|
||||||
|
log.info("超时团组处理完成: groupId={}", group.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 300000) // 每5分钟
|
||||||
|
@Transactional
|
||||||
|
public void scheduledUpdateStatus() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
// Activate pending activities whose start time has passed
|
||||||
|
List<GroupBuying> upcoming = groupBuyingRepository.findUpcomingGroupBuyings(now);
|
||||||
|
// These are status=1 and startTime > now, so nothing to activate here
|
||||||
|
|
||||||
|
// Actually find activities that should be active: status=1, startTime <= now, endTime > now
|
||||||
|
List<GroupBuying> all = groupBuyingRepository.findAll();
|
||||||
|
for (GroupBuying gb : all) {
|
||||||
|
if (gb.getStatus() == 1 && !now.isBefore(gb.getStartTime()) && now.isBefore(gb.getEndTime())) {
|
||||||
|
groupBuyingRepository.updateStatus(gb.getId(), 2);
|
||||||
|
preloadStock(gb);
|
||||||
|
log.info("拼团活动已激活: id={}", gb.getId());
|
||||||
|
} else if (gb.getStatus() == 2 && !now.isBefore(gb.getEndTime())) {
|
||||||
|
groupBuyingRepository.updateStatus(gb.getId(), 3);
|
||||||
|
log.info("拼团活动已结束: id={}", gb.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void preloadStock(GroupBuying gb) {
|
||||||
|
String stockKey = GB_STOCK_PREFIX + gb.getId();
|
||||||
|
redisService.setString(stockKey, String.valueOf(gb.getRemainingStock()));
|
||||||
|
long ttl = java.time.Duration.between(LocalDateTime.now(), gb.getEndTime()).getSeconds() + 3600;
|
||||||
|
redisService.expire(stockKey, ttl, TimeUnit.SECONDS);
|
||||||
|
log.info("拼团库存预热: id={}, stock={}", gb.getId(), gb.getRemainingStock());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void preloadAllActiveStock() {
|
||||||
|
List<GroupBuying> activeList = groupBuyingRepository.findActiveGroupBuyings(LocalDateTime.now());
|
||||||
|
for (GroupBuying gb : activeList) {
|
||||||
|
preloadStock(gb);
|
||||||
|
}
|
||||||
|
log.info("所有拼团活动库存预热完成, count={}", activeList.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 构建 DTO ==========
|
||||||
|
|
||||||
|
private GroupBuyingDTO buildDTO(GroupBuying gb) {
|
||||||
|
GroupBuyingDTO dto = new GroupBuyingDTO();
|
||||||
|
dto.setId(gb.getId());
|
||||||
|
dto.setProductId(gb.getProductId());
|
||||||
|
dto.setGroupPrice(gb.getGroupPrice());
|
||||||
|
dto.setRequiredMembers(gb.getRequiredMembers());
|
||||||
|
dto.setDurationMinutes(gb.getDurationMinutes());
|
||||||
|
dto.setTotalStock(gb.getTotalStock());
|
||||||
|
dto.setRemainingStock(gb.getRemainingStock());
|
||||||
|
dto.setMaxPerUser(gb.getMaxPerUser());
|
||||||
|
dto.setStatus(gb.getStatus());
|
||||||
|
dto.setStatusDescription(getStatusDescription(gb.getStatus()));
|
||||||
|
dto.setStartTime(gb.getStartTime());
|
||||||
|
dto.setEndTime(gb.getEndTime());
|
||||||
|
dto.setCreatedAt(gb.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(gb.getUpdatedAt());
|
||||||
|
|
||||||
|
// Product info
|
||||||
|
ProductDTO product = productService.getProductById(gb.getProductId());
|
||||||
|
if (product != null) {
|
||||||
|
dto.setProductName(product.getName());
|
||||||
|
dto.setProductImageUrl(product.getImageUrl());
|
||||||
|
dto.setProductPrice(product.getPrice());
|
||||||
|
dto.setDiscount(product.getPrice().subtract(gb.getGroupPrice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active group count
|
||||||
|
long activeGroups = groupBuyingGroupRepository.countByGroupBuyingIdAndStatus(gb.getId(), 1);
|
||||||
|
dto.setActiveGroupCount((int) activeGroups);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBuyingDTO.GroupInfoDTO buildGroupInfoDTO(GroupBuyingGroup group) {
|
||||||
|
GroupBuyingDTO.GroupInfoDTO dto = new GroupBuyingDTO.GroupInfoDTO();
|
||||||
|
dto.setId(group.getId());
|
||||||
|
dto.setGroupNo(group.getGroupNo());
|
||||||
|
dto.setGroupBuyingId(group.getGroupBuyingId());
|
||||||
|
dto.setLeaderUserId(group.getLeaderUserId());
|
||||||
|
dto.setRequiredMembers(group.getRequiredMembers());
|
||||||
|
dto.setCurrentMembers(group.getCurrentMembers());
|
||||||
|
dto.setStatus(group.getStatus());
|
||||||
|
dto.setStatusDescription(getGroupStatusDescription(group.getStatus()));
|
||||||
|
dto.setExpireTime(group.getExpireTime());
|
||||||
|
dto.setCreatedAt(group.getCreatedAt());
|
||||||
|
dto.setCompletedAt(group.getCompletedAt());
|
||||||
|
|
||||||
|
// Leader info
|
||||||
|
UserDTO leader = userService.getUserById(group.getLeaderUserId());
|
||||||
|
if (leader != null) {
|
||||||
|
dto.setLeaderUsername(leader.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members
|
||||||
|
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupId(group.getId());
|
||||||
|
List<GroupBuyingDTO.MemberDTO> memberDTOs = members.stream()
|
||||||
|
.filter(m -> m.getStatus() != 3) // exclude exited
|
||||||
|
.map(m -> {
|
||||||
|
GroupBuyingDTO.MemberDTO memberDTO = new GroupBuyingDTO.MemberDTO();
|
||||||
|
memberDTO.setId(m.getId());
|
||||||
|
memberDTO.setUserId(m.getUserId());
|
||||||
|
memberDTO.setOrderId(m.getOrderId());
|
||||||
|
memberDTO.setStatus(m.getStatus());
|
||||||
|
memberDTO.setJoinedAt(m.getJoinedAt());
|
||||||
|
UserDTO user = userService.getUserById(m.getUserId());
|
||||||
|
if (user != null) {
|
||||||
|
memberDTO.setUsername(user.getUsername());
|
||||||
|
memberDTO.setAvatar(user.getAvatar());
|
||||||
|
}
|
||||||
|
return memberDTO;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
dto.setMembers(memberDTOs);
|
||||||
|
|
||||||
|
// Activity info
|
||||||
|
GroupBuying gb = groupBuyingRepository.findById(group.getGroupBuyingId()).orElse(null);
|
||||||
|
if (gb != null) {
|
||||||
|
dto.setGroupBuying(buildDTO(gb));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getStatusDescription(Integer status) {
|
||||||
|
if (status == null) return "未知";
|
||||||
|
switch (status) {
|
||||||
|
case 0: return "草稿";
|
||||||
|
case 1: return "未开始";
|
||||||
|
case 2: return "进行中";
|
||||||
|
case 3: return "已结束";
|
||||||
|
default: return "未知";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getGroupStatusDescription(Integer status) {
|
||||||
|
if (status == null) return "未知";
|
||||||
|
switch (status) {
|
||||||
|
case 1: return "拼团中";
|
||||||
|
case 2: return "已成团";
|
||||||
|
case 3: return "已失败";
|
||||||
|
default: return "未知";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ public class MessageListenerService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private NotificationService notificationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化消息监听器
|
* 初始化消息监听器
|
||||||
*/
|
*/
|
||||||
@@ -62,31 +65,43 @@ public class MessageListenerService {
|
|||||||
* 处理订单状态变更
|
* 处理订单状态变更
|
||||||
*/
|
*/
|
||||||
private void handleOrderStatusChange(Long orderId, Long userId, Integer status, String action) {
|
private void handleOrderStatusChange(Long orderId, Long userId, Integer status, String action) {
|
||||||
// 可以在这里实现:
|
if (userId == null) {
|
||||||
// 1. 发送邮件通知
|
log.warn("订单状态变更缺少用户ID: orderId={}", orderId);
|
||||||
// 2. 推送消息
|
return;
|
||||||
// 3. 更新统计数据
|
}
|
||||||
// 4. 触发其他业务流程
|
|
||||||
|
String title;
|
||||||
|
String message;
|
||||||
|
String link = "/order/" + orderId;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "created":
|
case "created":
|
||||||
log.info("订单创建通知处理: 订单ID={}", orderId);
|
title = "订单创建成功";
|
||||||
|
message = "您的订单 #" + orderId + " 已创建,请尽快完成支付";
|
||||||
break;
|
break;
|
||||||
case "paid":
|
case "paid":
|
||||||
log.info("订单支付通知处理: 订单ID={}", orderId);
|
title = "订单支付成功";
|
||||||
|
message = "您的订单 #" + orderId + " 已支付成功,等待商家发货";
|
||||||
break;
|
break;
|
||||||
case "shipped":
|
case "shipped":
|
||||||
log.info("订单发货通知处理: 订单ID={}", orderId);
|
title = "订单已发货";
|
||||||
|
message = "您的订单 #" + orderId + " 已发货,请注意查收";
|
||||||
break;
|
break;
|
||||||
case "completed":
|
case "completed":
|
||||||
log.info("订单完成通知处理: 订单ID={}", orderId);
|
title = "订单已完成";
|
||||||
|
message = "您的订单 #" + orderId + " 已完成,欢迎评价";
|
||||||
break;
|
break;
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
log.info("订单取消通知处理: 订单ID={}", orderId);
|
title = "订单已取消";
|
||||||
|
message = "您的订单 #" + orderId + " 已取消";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
log.info("未知订单状态变更: {}", action);
|
log.info("未知订单状态变更: {}", action);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notificationService.createNotification(userId, "order", title, message, link);
|
||||||
|
log.info("订单状态变更通知已创建: 订单ID={}, 操作={}", orderId, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,20 +127,23 @@ public class MessageListenerService {
|
|||||||
* 处理秒杀结果
|
* 处理秒杀结果
|
||||||
*/
|
*/
|
||||||
private void handleFlashSaleResult(Long userId, Long flashSaleId, Boolean success, Map<String, Object> data) {
|
private void handleFlashSaleResult(Long userId, Long flashSaleId, Boolean success, Map<String, Object> data) {
|
||||||
// 可以在这里实现:
|
if (userId == null) {
|
||||||
// 1. 实时通知用户
|
log.warn("秒杀结果缺少用户ID: flashSaleId={}", flashSaleId);
|
||||||
// 2. 统计秒杀数据
|
return;
|
||||||
// 3. 风控分析
|
}
|
||||||
// 4. 营销推荐
|
|
||||||
|
String link = "/flashsale/" + flashSaleId;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
log.info("秒杀成功处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
String title = "秒杀成功";
|
||||||
// 发送成功通知
|
String message = "恭喜您成功抢购秒杀商品,请尽快完成支付!";
|
||||||
sendFlashSaleSuccessNotification(userId, flashSaleId);
|
notificationService.createNotification(userId, "flashsale", title, message, link);
|
||||||
|
log.info("秒杀成功通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
||||||
} else {
|
} else {
|
||||||
log.info("秒杀失败处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
String title = "秒杀未中";
|
||||||
// 可以推荐其他商品
|
String message = "很遗憾,本次秒杀未能抢购成功,下次再来吧!";
|
||||||
recommendAlternativeProducts(userId, flashSaleId);
|
notificationService.createNotification(userId, "flashsale", title, message, link);
|
||||||
|
log.info("秒杀失败通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,26 +179,9 @@ public class MessageListenerService {
|
|||||||
* 检查库存预警
|
* 检查库存预警
|
||||||
*/
|
*/
|
||||||
private void checkStockAlert(Long productId) {
|
private void checkStockAlert(Long productId) {
|
||||||
// 实现库存预警逻辑
|
|
||||||
log.debug("检查商品库存预警: 商品ID={}", productId);
|
log.debug("检查商品库存预警: 商品ID={}", productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送秒杀成功通知
|
|
||||||
*/
|
|
||||||
private void sendFlashSaleSuccessNotification(Long userId, Long flashSaleId) {
|
|
||||||
// 实现成功通知逻辑
|
|
||||||
log.debug("发送秒杀成功通知: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推荐替代商品
|
|
||||||
*/
|
|
||||||
private void recommendAlternativeProducts(Long userId, Long flashSaleId) {
|
|
||||||
// 实现商品推荐逻辑
|
|
||||||
log.debug("推荐替代商品: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提取Long值
|
* 提取Long值
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.org.flashsalesystem.service;
|
||||||
|
|
||||||
|
import com.org.flashsalesystem.entity.Notification;
|
||||||
|
import com.org.flashsalesystem.repository.NotificationRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private NotificationRepository notificationRepository;
|
||||||
|
|
||||||
|
public void createNotification(Long userId, String type, String title, String message, String link) {
|
||||||
|
Notification notification = new Notification();
|
||||||
|
notification.setUserId(userId);
|
||||||
|
notification.setType(type);
|
||||||
|
notification.setTitle(title);
|
||||||
|
notification.setMessage(message);
|
||||||
|
notification.setLink(link);
|
||||||
|
notification.setRead(false);
|
||||||
|
notificationRepository.save(notification);
|
||||||
|
log.debug("通知已创建: userId={}, type={}, title={}", userId, type, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Notification> getUserNotifications(Long userId) {
|
||||||
|
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Notification> getUserNotificationsByType(Long userId, String type) {
|
||||||
|
return notificationRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUnreadCount(Long userId) {
|
||||||
|
return notificationRepository.countByUserIdAndReadFalse(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markAsRead(Long notificationId, Long userId) {
|
||||||
|
notificationRepository.markAsRead(notificationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markAllAsRead(Long userId) {
|
||||||
|
notificationRepository.markAllAsRead(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void clearAll(Long userId) {
|
||||||
|
notificationRepository.deleteByUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,10 @@ public class OrderService {
|
|||||||
private UserService userService;
|
private UserService userService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserAddressRepository userAddressRepository;
|
private UserAddressRepository userAddressRepository;
|
||||||
|
@Autowired
|
||||||
|
private FlashSaleService flashSaleService;
|
||||||
|
@Autowired
|
||||||
|
private GroupBuyingService groupBuyingService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建普通订单
|
* 创建普通订单
|
||||||
@@ -219,14 +223,20 @@ public class OrderService {
|
|||||||
* 获取用户订单列表
|
* 获取用户订单列表
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> getUserOrders(Long userId, OrderDTO.QueryDTO queryDTO) {
|
public Map<String, Object> getUserOrders(Long userId, OrderDTO.QueryDTO queryDTO) {
|
||||||
|
// 限制分页大小
|
||||||
|
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||||
// 构建分页和排序
|
// 构建分页和排序
|
||||||
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
|
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
|
||||||
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
|
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
|
||||||
|
|
||||||
Page<Order> orderPage;
|
Page<Order> orderPage;
|
||||||
|
|
||||||
// 根据查询条件获取订单
|
// 根据查询条件获取订单
|
||||||
if (queryDTO.getStatus() != null) {
|
if (queryDTO.getStatus() != null && queryDTO.getOrderType() != null) {
|
||||||
|
orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable);
|
||||||
|
} else if (queryDTO.getOrderType() != null) {
|
||||||
|
orderPage = orderRepository.findByUserIdAndOrderType(userId, queryDTO.getOrderType(), pageable);
|
||||||
|
} else if (queryDTO.getStatus() != null) {
|
||||||
orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable);
|
orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable);
|
||||||
} else {
|
} else {
|
||||||
orderPage = orderRepository.findByUserId(userId, pageable);
|
orderPage = orderRepository.findByUserId(userId, pageable);
|
||||||
@@ -259,9 +269,11 @@ public class OrderService {
|
|||||||
* 获取所有订单列表(管理员)
|
* 获取所有订单列表(管理员)
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> getAllOrders(OrderDTO.QueryDTO queryDTO) {
|
public Map<String, Object> getAllOrders(OrderDTO.QueryDTO queryDTO) {
|
||||||
|
// 限制分页大小
|
||||||
|
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||||
// 构建分页和排序
|
// 构建分页和排序
|
||||||
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
|
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
|
||||||
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
|
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
|
||||||
|
|
||||||
Page<Order> orderPage;
|
Page<Order> orderPage;
|
||||||
|
|
||||||
@@ -402,6 +414,21 @@ public class OrderService {
|
|||||||
// 恢复库存
|
// 恢复库存
|
||||||
productService.updateStock(order.getProductId(), order.getQuantity(), "increase");
|
productService.updateStock(order.getProductId(), order.getQuantity(), "increase");
|
||||||
|
|
||||||
|
// 秒杀订单额外恢复秒杀库存
|
||||||
|
if (order.getOrderType() != null && order.getOrderType() == 2) {
|
||||||
|
flashSaleService.restoreFlashSaleStock(order.getFlashSaleId(), order.getProductId(), order.getCreatedAt(),
|
||||||
|
order.getUserId(), order.getQuantity());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼团订单额外处理
|
||||||
|
if (order.getOrderType() != null && order.getOrderType() == 3 && order.getGroupBuyingGroupId() != null) {
|
||||||
|
try {
|
||||||
|
groupBuyingService.cancelMembership(order.getGroupBuyingGroupId(), order.getUserId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("拼团退出处理失败: orderId={}, error={}", orderId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
cacheOrderInfo(order);
|
cacheOrderInfo(order);
|
||||||
|
|
||||||
@@ -569,6 +596,7 @@ public class OrderService {
|
|||||||
orderMap.put("groupNo", order.getGroupNo() == null ? "" : order.getGroupNo());
|
orderMap.put("groupNo", order.getGroupNo() == null ? "" : order.getGroupNo());
|
||||||
orderMap.put("userId", order.getUserId().toString());
|
orderMap.put("userId", order.getUserId().toString());
|
||||||
orderMap.put("productId", order.getProductId().toString());
|
orderMap.put("productId", order.getProductId().toString());
|
||||||
|
orderMap.put("flashSaleId", order.getFlashSaleId() == null ? "" : order.getFlashSaleId().toString());
|
||||||
orderMap.put("quantity", order.getQuantity().toString());
|
orderMap.put("quantity", order.getQuantity().toString());
|
||||||
orderMap.put("totalPrice", order.getTotalPrice().toString());
|
orderMap.put("totalPrice", order.getTotalPrice().toString());
|
||||||
orderMap.put("status", order.getStatus().toString());
|
orderMap.put("status", order.getStatus().toString());
|
||||||
@@ -603,6 +631,8 @@ public class OrderService {
|
|||||||
orderDTO.setGroupNo((String) orderMap.get("groupNo"));
|
orderDTO.setGroupNo((String) orderMap.get("groupNo"));
|
||||||
orderDTO.setUserId(Long.valueOf((String) orderMap.get("userId")));
|
orderDTO.setUserId(Long.valueOf((String) orderMap.get("userId")));
|
||||||
orderDTO.setProductId(Long.valueOf((String) orderMap.get("productId")));
|
orderDTO.setProductId(Long.valueOf((String) orderMap.get("productId")));
|
||||||
|
String flashSaleId = (String) orderMap.get("flashSaleId");
|
||||||
|
if (flashSaleId != null && !flashSaleId.isEmpty()) { orderDTO.setFlashSaleId(Long.valueOf(flashSaleId)); }
|
||||||
orderDTO.setQuantity(Integer.valueOf((String) orderMap.get("quantity")));
|
orderDTO.setQuantity(Integer.valueOf((String) orderMap.get("quantity")));
|
||||||
orderDTO.setTotalPrice(new BigDecimal((String) orderMap.get("totalPrice")));
|
orderDTO.setTotalPrice(new BigDecimal((String) orderMap.get("totalPrice")));
|
||||||
orderDTO.setStatus(Integer.valueOf((String) orderMap.get("status")));
|
orderDTO.setStatus(Integer.valueOf((String) orderMap.get("status")));
|
||||||
@@ -849,6 +879,8 @@ public class OrderService {
|
|||||||
return "普通订单";
|
return "普通订单";
|
||||||
case 2:
|
case 2:
|
||||||
return "秒杀订单";
|
return "秒杀订单";
|
||||||
|
case 3:
|
||||||
|
return "拼团订单";
|
||||||
default:
|
default:
|
||||||
return "未知类型";
|
return "未知类型";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import com.org.flashsalesystem.dto.ProductReviewDTO;
|
|||||||
import com.org.flashsalesystem.dto.UserDTO;
|
import com.org.flashsalesystem.dto.UserDTO;
|
||||||
import com.org.flashsalesystem.entity.Order;
|
import com.org.flashsalesystem.entity.Order;
|
||||||
import com.org.flashsalesystem.entity.OrderItem;
|
import com.org.flashsalesystem.entity.OrderItem;
|
||||||
|
import com.org.flashsalesystem.entity.Product;
|
||||||
import com.org.flashsalesystem.entity.ProductReview;
|
import com.org.flashsalesystem.entity.ProductReview;
|
||||||
import com.org.flashsalesystem.repository.OrderItemRepository;
|
import com.org.flashsalesystem.repository.OrderItemRepository;
|
||||||
import com.org.flashsalesystem.repository.OrderRepository;
|
import com.org.flashsalesystem.repository.OrderRepository;
|
||||||
|
import com.org.flashsalesystem.repository.ProductRepository;
|
||||||
import com.org.flashsalesystem.repository.ProductReviewRepository;
|
import com.org.flashsalesystem.repository.ProductReviewRepository;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
@@ -15,6 +17,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -30,6 +33,9 @@ public class ProductReviewService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private OrderItemRepository orderItemRepository;
|
private OrderItemRepository orderItemRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ProductRepository productRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@@ -68,7 +74,7 @@ public class ProductReviewService {
|
|||||||
.map(this::toDTO)
|
.map(this::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Double average = productReviewRepository.findAverageRatingByProductId(productId);
|
Double average = productReviewRepository.findAverageRatingByProductId(productId);
|
||||||
Long total = productReviewRepository.countByProductId(productId);
|
Long total = productReviewRepository.countByProductIdAndStatus(productId, 1);
|
||||||
return new ProductReviewDTO.SummaryDTO(average == null ? 0.0 : average, total, reviews);
|
return new ProductReviewDTO.SummaryDTO(average == null ? 0.0 : average, total, reviews);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +95,39 @@ public class ProductReviewService {
|
|||||||
return toDTO(review);
|
return toDTO(review);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ProductReviewDTO.CheckDTO checkReviewStatus(Long orderId, Long productId) {
|
||||||
|
ProductReviewDTO.CheckDTO checkDTO = new ProductReviewDTO.CheckDTO();
|
||||||
|
Optional<ProductReview> review = productReviewRepository.findByOrderIdAndProductId(orderId, productId);
|
||||||
|
checkDTO.setReviewed(review.isPresent());
|
||||||
|
review.ifPresent(r -> checkDTO.setReview(toDTO(r)));
|
||||||
|
return checkDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProductReviewDTO> getUserReviews(Long userId) {
|
||||||
|
return productReviewRepository.findByUserIdOrderByCreatedAtDesc(userId)
|
||||||
|
.stream()
|
||||||
|
.map(this::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProductReviewDTO> getOrderReviews(Long orderId) {
|
||||||
|
return productReviewRepository.findByOrderId(orderId)
|
||||||
|
.stream()
|
||||||
|
.map(this::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
private ProductReviewDTO toDTO(ProductReview review) {
|
private ProductReviewDTO toDTO(ProductReview review) {
|
||||||
ProductReviewDTO dto = new ProductReviewDTO();
|
ProductReviewDTO dto = new ProductReviewDTO();
|
||||||
BeanUtils.copyProperties(review, dto);
|
BeanUtils.copyProperties(review, dto);
|
||||||
UserDTO user = userService.getUserById(review.getUserId());
|
UserDTO user = userService.getUserById(review.getUserId());
|
||||||
dto.setUsername(user != null ? user.getUsername() : "匿名用户");
|
dto.setUsername(user != null ? user.getUsername() : "匿名用户");
|
||||||
dto.setStatusText(review.getStatus() != null && review.getStatus() == 1 ? "显示" : "隐藏");
|
dto.setStatusText(review.getStatus() != null && review.getStatus() == 1 ? "显示" : "隐藏");
|
||||||
|
Optional<Product> product = productRepository.findById(review.getProductId());
|
||||||
|
if (product.isPresent()) {
|
||||||
|
dto.setProductName(product.get().getName());
|
||||||
|
dto.setProductImage(product.get().getImageUrl());
|
||||||
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,9 +120,11 @@ public class ProductService {
|
|||||||
return (Map<String, Object>) cachedResult;
|
return (Map<String, Object>) cachedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 限制分页大小
|
||||||
|
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||||
// 构建分页和排序
|
// 构建分页和排序
|
||||||
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
|
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
|
||||||
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
|
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
|
||||||
|
|
||||||
Integer status = queryDTO.getStatus() != null ? queryDTO.getStatus() : 1;
|
Integer status = queryDTO.getStatus() != null ? queryDTO.getStatus() : 1;
|
||||||
String keyword = queryDTO.getKeyword() != null && !queryDTO.getKeyword().trim().isEmpty()
|
String keyword = queryDTO.getKeyword() != null && !queryDTO.getKeyword().trim().isEmpty()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
|
session:
|
||||||
|
timeout: 30m
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
|
|||||||
32
src/main/resources/lua/groupbuying_stock.lua
Normal file
32
src/main/resources/lua/groupbuying_stock.lua
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- 拼团库存扣减Lua脚本
|
||||||
|
-- 功能:原子性地检查库存并扣减,防止超卖
|
||||||
|
-- 参数:KEYS[1] = 库存key, ARGV[1] = 扣减数量
|
||||||
|
-- 返回值:成功返回剩余库存,失败返回负数
|
||||||
|
|
||||||
|
local stock_key = KEYS[1]
|
||||||
|
local quantity_str = ARGV[1]
|
||||||
|
local quantity = tonumber(quantity_str)
|
||||||
|
|
||||||
|
if quantity == nil or quantity <= 0 then
|
||||||
|
return -3
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_stock = redis.call('GET', stock_key)
|
||||||
|
|
||||||
|
if current_stock == false then
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_stock_num = tonumber(current_stock)
|
||||||
|
|
||||||
|
if current_stock_num == nil then
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_stock_num < quantity then
|
||||||
|
return -2
|
||||||
|
end
|
||||||
|
|
||||||
|
local remaining_stock = redis.call('DECRBY', stock_key, quantity)
|
||||||
|
|
||||||
|
return remaining_stock
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
-- 演示账号快速创建脚本
|
-- 演示账号初始化脚本
|
||||||
-- 密码都是明文对应的值:demo1/demo2/admin的密码分别是123456/123456/admin123
|
-- 账号:demo1 / 123456,demo2 / 123456,admin / admin123
|
||||||
|
|
||||||
USE flash_sale_db;
|
USE flash_sale_db;
|
||||||
|
|
||||||
-- 插入演示用户(密码已加密)
|
INSERT INTO users (username, password, email, phone, avatar, role, status, created_at, updated_at)
|
||||||
INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at)
|
VALUES
|
||||||
VALUES ('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', 'USER', 1,
|
('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', '', 'USER', 1, NOW(), NOW()),
|
||||||
NOW(), NOW()),
|
('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', '', 'USER', 1, NOW(), NOW()),
|
||||||
('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', 'USER', 1,
|
('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', '', 'ADMIN', 1, NOW(), NOW())
|
||||||
NOW(), NOW()),
|
ON DUPLICATE KEY UPDATE
|
||||||
('admin', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1,
|
email = VALUES(email),
|
||||||
NOW(), NOW())
|
phone = VALUES(phone),
|
||||||
ON DUPLICATE KEY UPDATE username = VALUES(username),
|
avatar = VALUES(avatar),
|
||||||
email = VALUES(email),
|
role = VALUES(role),
|
||||||
phone = VALUES(phone),
|
status = VALUES(status),
|
||||||
updated_at = NOW();
|
updated_at = NOW();
|
||||||
|
|
||||||
-- 验证插入结果
|
|
||||||
SELECT id, username, email, phone, status, created_at
|
|
||||||
FROM users
|
|
||||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
-- 修复演示账号密码问题
|
|
||||||
-- 使用正确的BCrypt加密密码
|
|
||||||
|
|
||||||
USE flash_sale_db;
|
|
||||||
|
|
||||||
-- 删除现有的演示用户(如果存在)
|
|
||||||
DELETE
|
|
||||||
FROM users
|
|
||||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
|
||||||
|
|
||||||
-- 插入正确的演示用户
|
|
||||||
-- demo1/demo2 密码: 123456
|
|
||||||
-- admin 密码: admin123
|
|
||||||
INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at)
|
|
||||||
VALUES ('demo1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo1@example.com', '13800138001', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('demo2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo2@example.com', '13800138002', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1,
|
|
||||||
NOW(), NOW());
|
|
||||||
|
|
||||||
-- 验证插入结果
|
|
||||||
SELECT id, username, email, phone, status, created_at
|
|
||||||
FROM users
|
|
||||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
|
||||||
|
|
||||||
-- 显示密码提示
|
|
||||||
SELECT '演示账号密码信息:' as info;
|
|
||||||
SELECT 'demo1 / 123456' as account_info
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'demo2 / 123456'
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'admin / admin123';
|
|
||||||
@@ -1,31 +1,32 @@
|
|||||||
-- 秒杀系统数据库表结构
|
-- 秒杀系统数据库结构
|
||||||
-- 创建数据库和所有必要的表
|
-- 说明:本脚本只负责数据库对象定义,不包含演示数据。
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS flash_sale_db
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 创建数据库
|
|
||||||
CREATE DATABASE IF NOT EXISTS flash_sale_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
USE flash_sale_db;
|
USE flash_sale_db;
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 1. 用户表
|
-- 1. 用户表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS users
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
|
||||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||||
password VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
password VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
||||||
email VARCHAR(100) COMMENT '邮箱',
|
email VARCHAR(100) COMMENT '邮箱',
|
||||||
phone VARCHAR(20) COMMENT '手机号',
|
phone VARCHAR(20) COMMENT '手机号',
|
||||||
avatar VARCHAR(500) COMMENT '头像',
|
avatar VARCHAR(500) COMMENT '头像',
|
||||||
role VARCHAR(20) DEFAULT 'USER' COMMENT '角色:ADMIN/USER',
|
role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:ADMIN/USER',
|
||||||
status TINYINT DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
last_login TIMESTAMP NULL COMMENT '最后登录时间',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
INDEX idx_username (username),
|
INDEX idx_users_username (username),
|
||||||
INDEX idx_email (email),
|
INDEX idx_users_email (email),
|
||||||
INDEX idx_phone (phone),
|
INDEX idx_users_phone (phone),
|
||||||
INDEX idx_status (status),
|
INDEX idx_users_status (status),
|
||||||
INDEX idx_created_at (created_at)
|
INDEX idx_users_created_at (created_at)
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='用户表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='用户表';
|
||||||
@@ -33,8 +34,7 @@ CREATE TABLE IF NOT EXISTS users
|
|||||||
-- ================================
|
-- ================================
|
||||||
-- 2. 商品表
|
-- 2. 商品表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS products
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID',
|
||||||
name VARCHAR(200) NOT NULL COMMENT '商品名称',
|
name VARCHAR(200) NOT NULL COMMENT '商品名称',
|
||||||
description TEXT COMMENT '商品描述',
|
description TEXT COMMENT '商品描述',
|
||||||
@@ -42,15 +42,15 @@ CREATE TABLE IF NOT EXISTS products
|
|||||||
category VARCHAR(100) COMMENT '商品分类',
|
category VARCHAR(100) COMMENT '商品分类',
|
||||||
stock INT NOT NULL DEFAULT 0 COMMENT '库存数量',
|
stock INT NOT NULL DEFAULT 0 COMMENT '库存数量',
|
||||||
image_url VARCHAR(500) COMMENT '商品图片URL',
|
image_url VARCHAR(500) COMMENT '商品图片URL',
|
||||||
status TINYINT DEFAULT 1 COMMENT '状态:1-上架,0-下架',
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-上架,0-下架',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
INDEX idx_products_name (name),
|
||||||
INDEX idx_name (name),
|
INDEX idx_products_category (category),
|
||||||
INDEX idx_price (price),
|
INDEX idx_products_price (price),
|
||||||
INDEX idx_stock (stock),
|
INDEX idx_products_stock (stock),
|
||||||
INDEX idx_status (status),
|
INDEX idx_products_status (status),
|
||||||
INDEX idx_created_at (created_at)
|
INDEX idx_products_created_at (created_at)
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='商品表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='商品表';
|
||||||
@@ -58,42 +58,41 @@ CREATE TABLE IF NOT EXISTS products
|
|||||||
-- ================================
|
-- ================================
|
||||||
-- 3. 秒杀活动表
|
-- 3. 秒杀活动表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS flash_sales
|
CREATE TABLE IF NOT EXISTS flash_sales (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '秒杀活动ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '秒杀活动ID',
|
||||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||||
flash_price DECIMAL(10, 2) NOT NULL COMMENT '秒杀价格',
|
flash_price DECIMAL(10, 2) NOT NULL COMMENT '秒杀价格',
|
||||||
flash_stock INT NOT NULL COMMENT '秒杀库存',
|
flash_stock INT NOT NULL COMMENT '秒杀库存',
|
||||||
start_time TIMESTAMP NOT NULL COMMENT '开始时间',
|
start_time TIMESTAMP NOT NULL COMMENT '开始时间',
|
||||||
end_time TIMESTAMP NOT NULL COMMENT '结束时间',
|
end_time TIMESTAMP NOT NULL COMMENT '结束时间',
|
||||||
status TINYINT DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束',
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
CONSTRAINT fk_flash_sales_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
INDEX idx_flash_sales_product_id (product_id),
|
||||||
INDEX idx_product_id (product_id),
|
INDEX idx_flash_sales_start_time (start_time),
|
||||||
INDEX idx_start_time (start_time),
|
INDEX idx_flash_sales_end_time (end_time),
|
||||||
INDEX idx_end_time (end_time),
|
INDEX idx_flash_sales_status (status),
|
||||||
INDEX idx_status (status),
|
INDEX idx_flash_sales_created_at (created_at)
|
||||||
INDEX idx_created_at (created_at)
|
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='秒杀活动表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='秒杀活动表';
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 4. 订单表
|
-- 4. 订单主表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS orders
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID',
|
||||||
order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号',
|
order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号',
|
||||||
group_no VARCHAR(64) COMMENT '聚合订单号',
|
group_no VARCHAR(64) COMMENT '聚合订单号(兼容旧数据)',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
product_id BIGINT NOT NULL COMMENT '兼容字段:主商品ID',
|
||||||
quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量',
|
flash_sale_id BIGINT COMMENT '秒杀活动ID',
|
||||||
total_price DECIMAL(10, 2) NOT NULL COMMENT '总价',
|
group_buying_group_id BIGINT COMMENT '拼团团组ID',
|
||||||
status TINYINT DEFAULT 1 COMMENT '状态:1-待支付,2-已支付,3-已发货,4-已完成,5-已取消',
|
quantity INT NOT NULL DEFAULT 1 COMMENT '兼容字段:总购买数量',
|
||||||
order_type TINYINT DEFAULT 1 COMMENT '订单类型:1-普通订单,2-秒杀订单',
|
total_price DECIMAL(10, 2) NOT NULL COMMENT '订单总价',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-待支付,2-已支付,3-已发货,4-已完成,5-已取消',
|
||||||
|
order_type TINYINT NOT NULL DEFAULT 1 COMMENT '订单类型:1-普通订单,2-秒杀订单',
|
||||||
receiver_name VARCHAR(100) COMMENT '收货人',
|
receiver_name VARCHAR(100) COMMENT '收货人',
|
||||||
receiver_phone VARCHAR(20) COMMENT '收货手机号',
|
receiver_phone VARCHAR(20) COMMENT '收货手机号',
|
||||||
receiver_address VARCHAR(255) COMMENT '收货地址',
|
receiver_address VARCHAR(255) COMMENT '收货地址',
|
||||||
@@ -102,30 +101,26 @@ CREATE TABLE IF NOT EXISTS orders
|
|||||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||||
shipped_at TIMESTAMP NULL COMMENT '发货时间',
|
shipped_at TIMESTAMP NULL COMMENT '发货时间',
|
||||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
CONSTRAINT fk_orders_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
INDEX idx_orders_order_no (order_no),
|
||||||
INDEX idx_user_id (user_id),
|
INDEX idx_orders_group_no (group_no),
|
||||||
INDEX idx_product_id (product_id),
|
INDEX idx_orders_user_id (user_id),
|
||||||
INDEX idx_status (status),
|
INDEX idx_orders_product_id (product_id),
|
||||||
INDEX idx_order_type (order_type),
|
INDEX idx_orders_flash_sale_id (flash_sale_id),
|
||||||
INDEX idx_created_at (created_at),
|
INDEX idx_orders_status (status),
|
||||||
INDEX idx_user_product (user_id, product_id)
|
INDEX idx_orders_order_type (order_type),
|
||||||
|
INDEX idx_orders_created_at (created_at)
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='订单表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='订单主表';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 5. 订单明细表
|
-- 5. 订单明细表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS order_items
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '明细ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '明细ID',
|
||||||
order_id BIGINT NOT NULL COMMENT '主订单ID',
|
order_id BIGINT NOT NULL COMMENT '主订单ID',
|
||||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||||
@@ -134,10 +129,9 @@ CREATE TABLE IF NOT EXISTS order_items
|
|||||||
price DECIMAL(10, 2) NOT NULL COMMENT '下单单价',
|
price DECIMAL(10, 2) NOT NULL COMMENT '下单单价',
|
||||||
quantity INT NOT NULL COMMENT '购买数量',
|
quantity INT NOT NULL COMMENT '购买数量',
|
||||||
subtotal DECIMAL(10, 2) NOT NULL COMMENT '小计',
|
subtotal DECIMAL(10, 2) NOT NULL COMMENT '小计',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
CONSTRAINT fk_order_items_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_order_items_order_id (order_id),
|
INDEX idx_order_items_order_id (order_id),
|
||||||
INDEX idx_order_items_product_id (product_id)
|
INDEX idx_order_items_product_id (product_id)
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
@@ -147,8 +141,7 @@ CREATE TABLE IF NOT EXISTS order_items
|
|||||||
-- ================================
|
-- ================================
|
||||||
-- 6. 用户地址表
|
-- 6. 用户地址表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS user_addresses
|
CREATE TABLE IF NOT EXISTS user_addresses (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '地址ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '地址ID',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
name VARCHAR(100) NOT NULL COMMENT '收货人',
|
name VARCHAR(100) NOT NULL COMMENT '收货人',
|
||||||
@@ -157,13 +150,12 @@ CREATE TABLE IF NOT EXISTS user_addresses
|
|||||||
city VARCHAR(50) COMMENT '城市',
|
city VARCHAR(50) COMMENT '城市',
|
||||||
district VARCHAR(50) COMMENT '区县',
|
district VARCHAR(50) COMMENT '区县',
|
||||||
address VARCHAR(255) NOT NULL COMMENT '详细地址',
|
address VARCHAR(255) NOT NULL COMMENT '详细地址',
|
||||||
is_default TINYINT DEFAULT 0 COMMENT '是否默认地址',
|
is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
CONSTRAINT fk_user_addresses_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
INDEX idx_user_addresses_user_id (user_id),
|
||||||
INDEX idx_address_user_id (user_id),
|
INDEX idx_user_addresses_default (is_default)
|
||||||
INDEX idx_address_default (is_default)
|
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='用户地址表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='用户地址表';
|
||||||
@@ -171,61 +163,127 @@ CREATE TABLE IF NOT EXISTS user_addresses
|
|||||||
-- ================================
|
-- ================================
|
||||||
-- 7. 商品评价表
|
-- 7. 商品评价表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS product_reviews
|
CREATE TABLE IF NOT EXISTS product_reviews (
|
||||||
(
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '评价ID',
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '评价ID',
|
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
rating TINYINT NOT NULL DEFAULT 5 COMMENT '评分',
|
||||||
rating TINYINT NOT NULL DEFAULT 5 COMMENT '评分',
|
content TEXT NOT NULL COMMENT '评价内容',
|
||||||
content TEXT NOT NULL COMMENT '评价内容',
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-显示,0-隐藏',
|
||||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-显示,0-隐藏',
|
|
||||||
admin_reply TEXT COMMENT '管理员回复',
|
admin_reply TEXT COMMENT '管理员回复',
|
||||||
replied_at TIMESTAMP NULL COMMENT '回复时间',
|
replied_at TIMESTAMP NULL COMMENT '回复时间',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
CONSTRAINT fk_product_reviews_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
CONSTRAINT fk_product_reviews_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
CONSTRAINT fk_product_reviews_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
UNIQUE KEY uk_review_order_user_product (order_id, user_id, product_id),
|
||||||
INDEX idx_review_product_id (product_id),
|
INDEX idx_product_reviews_product_id (product_id),
|
||||||
INDEX idx_review_user_id (user_id),
|
INDEX idx_product_reviews_user_id (user_id),
|
||||||
UNIQUE KEY uk_review_order_user (order_id, user_id)
|
INDEX idx_product_reviews_status (status)
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='商品评价表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='商品评价表';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 8. 用户收藏表
|
-- 8. 用户收藏表
|
||||||
-- ================================
|
-- ================================
|
||||||
CREATE TABLE IF NOT EXISTS user_favorites
|
CREATE TABLE IF NOT EXISTS user_favorites (
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '收藏ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '收藏ID',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
CONSTRAINT fk_user_favorites_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
CONSTRAINT fk_user_favorites_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
|
||||||
UNIQUE KEY uk_favorite_user_product (user_id, product_id),
|
UNIQUE KEY uk_favorite_user_product (user_id, product_id),
|
||||||
INDEX idx_favorite_user_id (user_id),
|
INDEX idx_user_favorites_user_id (user_id),
|
||||||
INDEX idx_favorite_product_id (product_id)
|
INDEX idx_user_favorites_product_id (product_id)
|
||||||
) ENGINE = InnoDB
|
) ENGINE = InnoDB
|
||||||
DEFAULT CHARSET = utf8mb4
|
DEFAULT CHARSET = utf8mb4
|
||||||
COLLATE = utf8mb4_unicode_ci COMMENT ='用户收藏表';
|
COLLATE = utf8mb4_unicode_ci COMMENT ='用户收藏表';
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 9. 创建视图(可选)
|
-- 9. 视图
|
||||||
-- ================================
|
-- ================================
|
||||||
|
-- ================================
|
||||||
|
-- 9. 拼团活动表
|
||||||
|
-- ================================
|
||||||
|
CREATE TABLE IF NOT EXISTS group_buying (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '拼团活动ID',
|
||||||
|
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||||
|
group_price DECIMAL(10, 2) NOT NULL COMMENT '拼团价格',
|
||||||
|
required_members INT NOT NULL DEFAULT 2 COMMENT '成团人数',
|
||||||
|
duration_minutes INT NOT NULL DEFAULT 1440 COMMENT '拼团有效期(分钟)',
|
||||||
|
total_stock INT NOT NULL COMMENT '总库存',
|
||||||
|
remaining_stock INT NOT NULL COMMENT '剩余库存',
|
||||||
|
max_per_user INT NOT NULL DEFAULT 1 COMMENT '每人限购',
|
||||||
|
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-草稿 1-未开始 2-进行中 3-已结束',
|
||||||
|
start_time DATETIME NOT NULL COMMENT '开始时间',
|
||||||
|
end_time DATETIME NOT NULL COMMENT '结束时间',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
CONSTRAINT fk_group_buying_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_group_buying_product_id (product_id),
|
||||||
|
INDEX idx_group_buying_status (status),
|
||||||
|
INDEX idx_group_buying_start_time (start_time),
|
||||||
|
INDEX idx_group_buying_end_time (end_time)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_unicode_ci COMMENT ='拼团活动表';
|
||||||
|
|
||||||
-- 活跃秒杀活动视图
|
-- ================================
|
||||||
|
-- 10. 拼团团组表
|
||||||
|
-- ================================
|
||||||
|
CREATE TABLE IF NOT EXISTS group_buying_group (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '团组ID',
|
||||||
|
group_no VARCHAR(64) NOT NULL UNIQUE COMMENT '团号',
|
||||||
|
group_buying_id BIGINT NOT NULL COMMENT '关联拼团活动',
|
||||||
|
leader_user_id BIGINT NOT NULL COMMENT '团长用户ID',
|
||||||
|
required_members INT NOT NULL COMMENT '需要人数',
|
||||||
|
current_members INT NOT NULL DEFAULT 1 COMMENT '当前人数',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-拼团中 2-已成团 3-已失败(超时)',
|
||||||
|
expire_time DATETIME NOT NULL COMMENT '过期时间',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
completed_at TIMESTAMP NULL COMMENT '成团时间',
|
||||||
|
CONSTRAINT fk_gbg_group_buying FOREIGN KEY (group_buying_id) REFERENCES group_buying (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_gbg_leader FOREIGN KEY (leader_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_gbg_group_no (group_no),
|
||||||
|
INDEX idx_gbg_group_buying_id (group_buying_id),
|
||||||
|
INDEX idx_gbg_status (status),
|
||||||
|
INDEX idx_gbg_expire_time (expire_time)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_unicode_ci COMMENT ='拼团团组表';
|
||||||
|
|
||||||
|
-- ================================
|
||||||
|
-- 11. 拼团成员表
|
||||||
|
-- ================================
|
||||||
|
CREATE TABLE IF NOT EXISTS group_buying_member (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID',
|
||||||
|
group_id BIGINT NOT NULL COMMENT '关联团组',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
|
order_id BIGINT COMMENT '关联订单',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-已加入 2-已成团 3-已退出',
|
||||||
|
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
|
||||||
|
CONSTRAINT fk_gbm_group FOREIGN KEY (group_id) REFERENCES group_buying_group (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_gbm_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_group_user (group_id, user_id),
|
||||||
|
INDEX idx_gbm_group_id (group_id),
|
||||||
|
INDEX idx_gbm_user_id (user_id),
|
||||||
|
INDEX idx_gbm_order_id (order_id)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_unicode_ci COMMENT ='拼团成员表';
|
||||||
|
|
||||||
|
-- ================================
|
||||||
|
-- 12. 视图
|
||||||
|
-- ================================
|
||||||
CREATE OR REPLACE VIEW active_flash_sales AS
|
CREATE OR REPLACE VIEW active_flash_sales AS
|
||||||
SELECT fs.id,
|
SELECT fs.id,
|
||||||
fs.product_id,
|
fs.product_id,
|
||||||
p.name as product_name,
|
p.name AS product_name,
|
||||||
p.price as original_price,
|
p.price AS original_price,
|
||||||
fs.flash_price,
|
fs.flash_price,
|
||||||
fs.flash_stock,
|
fs.flash_stock,
|
||||||
fs.start_time,
|
fs.start_time,
|
||||||
@@ -233,35 +291,20 @@ SELECT fs.id,
|
|||||||
fs.status,
|
fs.status,
|
||||||
p.image_url
|
p.image_url
|
||||||
FROM flash_sales fs
|
FROM flash_sales fs
|
||||||
JOIN products p ON fs.product_id = p.id
|
JOIN products p ON fs.product_id = p.id
|
||||||
WHERE fs.status = 2
|
WHERE fs.status = 2
|
||||||
AND fs.start_time <= NOW()
|
AND fs.start_time <= NOW()
|
||||||
AND fs.end_time > NOW()
|
AND fs.end_time > NOW()
|
||||||
AND p.status = 1;
|
AND p.status = 1;
|
||||||
|
|
||||||
-- 订单统计视图
|
|
||||||
CREATE OR REPLACE VIEW order_statistics AS
|
CREATE OR REPLACE VIEW order_statistics AS
|
||||||
SELECT DATE(created_at) as order_date,
|
SELECT DATE(created_at) AS order_date,
|
||||||
COUNT(*) as total_orders,
|
COUNT(*) AS total_orders,
|
||||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as pending_orders,
|
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS pending_orders,
|
||||||
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as paid_orders,
|
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS paid_orders,
|
||||||
SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) as completed_orders,
|
SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS completed_orders,
|
||||||
SUM(CASE WHEN order_type = 2 THEN 1 ELSE 0 END) as flash_sale_orders,
|
SUM(CASE WHEN order_type = 2 THEN 1 ELSE 0 END) AS flash_sale_orders,
|
||||||
SUM(total_price) as total_amount
|
SUM(total_price) AS total_amount
|
||||||
FROM orders
|
FROM orders
|
||||||
GROUP BY DATE(created_at)
|
GROUP BY DATE(created_at)
|
||||||
ORDER BY order_date DESC;
|
ORDER BY order_date DESC;
|
||||||
|
|
||||||
-- ================================
|
|
||||||
-- 6. 显示表结构
|
|
||||||
-- ================================
|
|
||||||
SHOW TABLES;
|
|
||||||
|
|
||||||
-- 显示表结构信息
|
|
||||||
SELECT TABLE_NAME as '表名',
|
|
||||||
TABLE_COMMENT as '表注释',
|
|
||||||
TABLE_ROWS as '估计行数'
|
|
||||||
FROM information_schema.TABLES
|
|
||||||
WHERE TABLE_SCHEMA = 'flash_sale_db'
|
|
||||||
AND TABLE_TYPE = 'BASE TABLE'
|
|
||||||
ORDER BY TABLE_NAME;
|
|
||||||
|
|||||||
@@ -1,161 +1,126 @@
|
|||||||
-- 秒杀系统测试数据SQL脚本
|
-- 测试业务数据初始化脚本
|
||||||
-- 包含演示账号、测试商品、秒杀活动等数据
|
-- 依赖:请先执行 schema.sql 和 demo-users.sql
|
||||||
|
|
||||||
-- 创建数据库(如果不存在)
|
|
||||||
CREATE DATABASE IF NOT EXISTS flash_sale_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
USE flash_sale_db;
|
USE flash_sale_db;
|
||||||
|
|
||||||
-- 清理现有数据(谨慎使用)
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
-- DELETE FROM orders WHERE id > 0;
|
DELETE FROM user_favorites;
|
||||||
-- DELETE FROM flash_sales WHERE id > 0;
|
DELETE FROM product_reviews;
|
||||||
-- DELETE FROM products WHERE id > 0;
|
DELETE FROM user_addresses;
|
||||||
-- DELETE FROM users WHERE id > 0;
|
DELETE FROM order_items;
|
||||||
|
DELETE FROM orders;
|
||||||
-- 重置自增ID
|
DELETE FROM flash_sales;
|
||||||
-- ALTER TABLE users AUTO_INCREMENT = 1;
|
DELETE FROM products;
|
||||||
-- ALTER TABLE products AUTO_INCREMENT = 1;
|
DELETE FROM users WHERE username LIKE 'testuser%';
|
||||||
-- ALTER TABLE flash_sales AUTO_INCREMENT = 1;
|
ALTER TABLE products AUTO_INCREMENT = 1;
|
||||||
-- ALTER TABLE orders AUTO_INCREMENT = 1;
|
ALTER TABLE flash_sales AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE orders AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE order_items AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE user_addresses AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE product_reviews AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE user_favorites AUTO_INCREMENT = 1;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 1. 插入测试用户数据
|
-- 1. 测试用户
|
||||||
-- ================================
|
-- ================================
|
||||||
|
INSERT INTO users (username, password, email, phone, avatar, role, status, created_at, updated_at)
|
||||||
INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at)
|
|
||||||
VALUES
|
VALUES
|
||||||
-- 演示账号(密码都是明文,实际应用中应该加密)
|
('testuser1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test1@example.com', '13800138003', '', 'USER', 1, NOW(), NOW()),
|
||||||
('demo1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo1@example.com', '13800138001', 'USER', 1, NOW(),
|
('testuser2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test2@example.com', '13800138004', '', 'USER', 1, NOW(), NOW()),
|
||||||
NOW()),
|
('testuser3', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test3@example.com', '13800138005', '', 'USER', 1, NOW(), NOW())
|
||||||
('demo2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo2@example.com', '13800138002', 'USER', 1, NOW(),
|
ON DUPLICATE KEY UPDATE
|
||||||
NOW()),
|
email = VALUES(email),
|
||||||
('admin', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1, NOW(),
|
phone = VALUES(phone),
|
||||||
NOW()),
|
updated_at = NOW();
|
||||||
|
|
||||||
-- 普通测试用户
|
|
||||||
('testuser1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test1@example.com', '13800138003', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('testuser2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test2@example.com', '13800138004', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('testuser3', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test3@example.com', '13800138005', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('testuser4', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test4@example.com', '13800138006', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('testuser5', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'test5@example.com', '13800138007', 'USER', 1,
|
|
||||||
NOW(), NOW());
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 2. 插入测试商品数据
|
-- 2. 商品
|
||||||
-- ================================
|
-- ================================
|
||||||
|
|
||||||
INSERT INTO products (name, description, price, category, stock, image_url, status, created_at, updated_at)
|
INSERT INTO products (name, description, price, category, stock, image_url, status, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
-- 电子产品类
|
('iPhone 15 Pro Max', '苹果最新旗舰手机,A17 Pro 芯片,钛金属设计。', 9999.00, '电子产品', 100, '/images/iphone15.svg', 1, NOW(), NOW()),
|
||||||
('iPhone 15 Pro Max', '苹果最新旗舰手机,A17 Pro芯片,钛金属设计', 9999.00, '电子产品', 100, '/images/iphone15.jpg', 1, NOW(), NOW()),
|
('MacBook Pro 16英寸', 'M3 Max 芯片,36GB 内存,1TB 存储。', 25999.00, '电子产品', 50, '/images/macbook.svg', 1, NOW(), NOW()),
|
||||||
('MacBook Pro 16英寸', 'M3 Max芯片,36GB内存,1TB存储', 25999.00, '电子产品', 50, '/images/macbook.jpg', 1, NOW(), NOW()),
|
('iPad Air', '10.9 英寸显示屏,轻薄便携。', 4399.00, '电子产品', 80, '/images/ipad.svg', 1, NOW(), NOW()),
|
||||||
('iPad Air', '10.9英寸液晶显示屏,M1芯片', 4399.00, '电子产品', 80, '/images/ipad.jpg', 1, NOW(), NOW()),
|
('AirPods Pro 2', '主动降噪无线耳机。', 1899.00, '电子产品', 200, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||||
('AirPods Pro 2', '主动降噪无线耳机,空间音频', 1899.00, '电子产品', 200, '/images/airpods.jpg', 1, NOW(), NOW()),
|
('Apple Watch Series 9', '健康监测与运动记录。', 3199.00, '电子产品', 150, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||||
('Apple Watch Series 9', '健康监测,GPS+蜂窝网络', 3199.00, '电子产品', 150, '/images/watch.jpg', 1, NOW(), NOW()),
|
('小米电视 65英寸', '4K 超高清,120Hz 刷新率。', 2999.00, '家电', 60, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||||
|
('戴森吸尘器 V15', '激光显微尘,强劲吸力。', 4690.00, '家电', 40, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||||
-- 家电类
|
('Nike Air Jordan 1', '经典篮球鞋,限量版配色。', 1299.00, '服饰鞋包', 120, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||||
('小米电视 65英寸', '4K超高清,120Hz刷新率', 2999.00, '家电', 60, '/images/tv.jpg', 1, NOW(), NOW()),
|
('深入理解Java虚拟机', 'JVM 原理与实践,第 3 版。', 89.00, '图书音像', 500, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||||
('戴森吸尘器 V15', '激光显微尘,强劲吸力', 4690.00, '家电', 40, '/images/dyson.jpg', 1, NOW(), NOW()),
|
('五常大米 10kg', '东北优质大米,香甜可口。', 168.00, '食品饮料', 200, '/images/default-product.svg', 1, NOW(), NOW());
|
||||||
('美的空调 1.5匹', '变频节能,静音运行', 2599.00, '家电', 80, '/images/airconditioner.jpg', 1, NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 服装类
|
|
||||||
('Nike Air Jordan 1', '经典篮球鞋,限量版配色', 1299.00, '服饰鞋包', 120, '/images/jordan.jpg', 1, NOW(), NOW()),
|
|
||||||
('Adidas Ultra Boost', '缓震跑鞋,Boost中底', 1599.00, '服饰鞋包', 100, '/images/ultraboost.jpg', 1, NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 图书类
|
|
||||||
('深入理解Java虚拟机', 'JVM原理与实践,第3版', 89.00, '图书音像', 500, '/images/jvm-book.jpg', 1, NOW(), NOW()),
|
|
||||||
('Redis设计与实现', 'Redis内部机制详解', 79.00, '图书音像', 300, '/images/redis-book.jpg', 1, NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 食品类
|
|
||||||
('茅台酒 53度 500ml', '国酒茅台,收藏佳品', 2680.00, '食品饮料', 30, '/images/maotai.jpg', 1, NOW(), NOW()),
|
|
||||||
('五常大米 10kg', '东北优质大米,香甜可口', 168.00, '食品饮料', 200, '/images/rice.jpg', 1, NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 美妆类
|
|
||||||
('SK-II神仙水 230ml', '护肤精华,改善肌肤', 1690.00, '美妆个护', 80, '/images/skii.jpg', 1, NOW(), NOW());
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 3. 插入秒杀活动数据
|
-- 3. 秒杀活动
|
||||||
-- ================================
|
-- ================================
|
||||||
|
|
||||||
INSERT INTO flash_sales (product_id, flash_price, flash_stock, start_time, end_time, status, created_at, updated_at)
|
INSERT INTO flash_sales (product_id, flash_price, flash_stock, start_time, end_time, status, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
-- 正在进行的秒杀活动
|
(1, 7999.00, 20, DATE_SUB(NOW(), INTERVAL 10 MINUTE), DATE_ADD(NOW(), INTERVAL 2 HOUR), 2, NOW(), NOW()),
|
||||||
(1, 7999.00, 20, DATE_SUB(NOW(), INTERVAL 10 MINUTE), DATE_ADD(NOW(), INTERVAL 2 HOUR), 2, NOW(), NOW()),
|
(4, 1299.00, 50, DATE_SUB(NOW(), INTERVAL 5 MINUTE), DATE_ADD(NOW(), INTERVAL 1 HOUR), 2, NOW(), NOW()),
|
||||||
(4, 1299.00, 50, DATE_SUB(NOW(), INTERVAL 5 MINUTE), DATE_ADD(NOW(), INTERVAL 1 HOUR), 2, NOW(), NOW()),
|
(6, 1999.00, 15, DATE_SUB(NOW(), INTERVAL 1 MINUTE), DATE_ADD(NOW(), INTERVAL 3 HOUR), 2, NOW(), NOW()),
|
||||||
(6, 1999.00, 15, DATE_SUB(NOW(), INTERVAL 1 MINUTE), DATE_ADD(NOW(), INTERVAL 3 HOUR), 2, NOW(), NOW()),
|
(2, 19999.00, 10, DATE_ADD(NOW(), INTERVAL 30 MINUTE), DATE_ADD(NOW(), INTERVAL 4 HOUR), 1, NOW(), NOW()),
|
||||||
|
(8, 899.00, 30, DATE_ADD(NOW(), INTERVAL 1 HOUR), DATE_ADD(NOW(), INTERVAL 5 HOUR), 1, NOW(), NOW()),
|
||||||
-- 即将开始的秒杀活动
|
(9, 59.00, 100, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 22 HOUR), 3, NOW(), NOW());
|
||||||
(2, 19999.00, 10, DATE_ADD(NOW(), INTERVAL 30 MINUTE), DATE_ADD(NOW(), INTERVAL 4 HOUR), 1, NOW(), NOW()),
|
|
||||||
(9, 899.00, 30, DATE_ADD(NOW(), INTERVAL 1 HOUR), DATE_ADD(NOW(), INTERVAL 5 HOUR), 1, NOW(), NOW()),
|
|
||||||
(13, 1999.00, 8, DATE_ADD(NOW(), INTERVAL 2 HOUR), DATE_ADD(NOW(), INTERVAL 6 HOUR), 1, NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 已结束的秒杀活动
|
|
||||||
(7, 3999.00, 10, DATE_SUB(NOW(), INTERVAL 2 HOUR), DATE_SUB(NOW(), INTERVAL 30 MINUTE), 3, NOW(), NOW()),
|
|
||||||
(11, 59.00, 100, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 22 HOUR), 3, NOW(), NOW());
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 4. 插入测试订单数据
|
-- 4. 地址
|
||||||
-- ================================
|
-- ================================
|
||||||
|
INSERT INTO user_addresses (user_id, name, phone, province, city, district, address, is_default, created_at, updated_at)
|
||||||
|
SELECT id, '演示用户一', '13800138001', '上海市', '上海市', '浦东新区', '张江高科技园区 100 号', 1, NOW(), NOW() FROM users WHERE username = 'demo1'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, '演示用户二', '13800138002', '浙江省', '杭州市', '西湖区', '文三路 88 号', 1, NOW(), NOW() FROM users WHERE username = 'demo2'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, '测试用户一', '13800138003', '广东省', '深圳市', '南山区', '科技园科苑路 18 号', 1, NOW(), NOW() FROM users WHERE username = 'testuser1';
|
||||||
|
|
||||||
INSERT INTO orders (user_id, product_id, quantity, total_price, status, order_type, created_at, updated_at)
|
-- ================================
|
||||||
|
-- 5. 订单主表
|
||||||
|
-- ================================
|
||||||
|
INSERT INTO orders (
|
||||||
|
order_no, group_no, user_id, product_id, flash_sale_id, quantity, total_price, status, order_type,
|
||||||
|
receiver_name, receiver_phone, receiver_address, remark, payment_method,
|
||||||
|
paid_at, shipped_at, completed_at, created_at, updated_at
|
||||||
|
)
|
||||||
VALUES
|
VALUES
|
||||||
-- demo1用户的订单
|
('ORD202603110001', NULL, (SELECT id FROM users WHERE username = 'demo1'), 9, NULL, 1, 89.00, 4, 1, '演示用户一', '13800138001', '上海市 上海市 浦东新区 张江高科技园区 100 号', '已完成测试订单', 'ALIPAY', DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)),
|
||||||
(1, 11, 1, 89.00, 4, 1, DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)),
|
('ORD202603110002', NULL, (SELECT id FROM users WHERE username = 'demo1'), 4, NULL, 1, 1899.00, 2, 1, '演示用户一', '13800138001', '上海市 上海市 浦东新区 张江高科技园区 100 号', '待发货测试订单', 'WECHAT', DATE_SUB(NOW(), INTERVAL 1 DAY), NULL, NULL, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)),
|
||||||
(1, 12, 1, 79.00, 2, 1, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY)),
|
('ORD202603110003', NULL, (SELECT id FROM users WHERE username = 'demo2'), 10, NULL, 1, 168.00, 3, 1, '演示用户二', '13800138002', '浙江省 杭州市 西湖区 文三路 88 号', '已发货测试订单', 'ONLINE', DATE_SUB(NOW(), INTERVAL 4 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR), NULL, DATE_SUB(NOW(), INTERVAL 6 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR)),
|
||||||
|
('ORD202603110004', NULL, (SELECT id FROM users WHERE username = 'demo2'), 4, 2, 1, 1299.00, 1, 2, '演示用户二', '13800138002', '浙江省 杭州市 西湖区 文三路 88 号', '秒杀待支付订单', NULL, NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 HOUR)),
|
||||||
-- demo2用户的订单
|
('ORD202603110005', NULL, (SELECT id FROM users WHERE username = 'testuser1'), 1, NULL, 2, 11798.00, 2, 1, '测试用户一', '13800138003', '广东省 深圳市 南山区 科技园科苑路 18 号', '多商品主订单', 'ONLINE', DATE_SUB(NOW(), INTERVAL 5 HOUR), NULL, NULL, DATE_SUB(NOW(), INTERVAL 5 HOUR), DATE_SUB(NOW(), INTERVAL 5 HOUR));
|
||||||
(2, 14, 1, 168.00, 3, 1, DATE_SUB(NOW(), INTERVAL 3 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR)),
|
|
||||||
(2, 7, 1, 3999.00, 1, 2, DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 HOUR)),
|
|
||||||
|
|
||||||
-- 其他用户的订单
|
|
||||||
(4, 15, 1, 1690.00, 2, 1, DATE_SUB(NOW(), INTERVAL 6 HOUR), DATE_SUB(NOW(), INTERVAL 5 HOUR)),
|
|
||||||
(5, 10, 1, 1599.00, 4, 1, DATE_SUB(NOW(), INTERVAL 12 HOUR), DATE_SUB(NOW(), INTERVAL 10 HOUR)),
|
|
||||||
(6, 8, 1, 2599.00, 3, 1, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 20 HOUR)),
|
|
||||||
(7, 5, 1, 3199.00, 2, 1, DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY));
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 5. 查询验证数据
|
-- 6. 订单明细
|
||||||
-- ================================
|
-- ================================
|
||||||
|
INSERT INTO order_items (order_id, product_id, product_name, product_image_url, price, quantity, subtotal, created_at)
|
||||||
-- 查看用户数据
|
SELECT o.id, 9, '深入理解Java虚拟机', '/images/default-product.svg', 89.00, 1, 89.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110001'
|
||||||
SELECT 'Users:' as table_name;
|
UNION ALL
|
||||||
SELECT id, username, email, phone, status, created_at
|
SELECT o.id, 4, 'AirPods Pro 2', '/images/default-product.svg', 1899.00, 1, 1899.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110002'
|
||||||
FROM users
|
UNION ALL
|
||||||
ORDER BY id;
|
SELECT o.id, 10, '五常大米 10kg', '/images/default-product.svg', 168.00, 1, 168.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110003'
|
||||||
|
UNION ALL
|
||||||
-- 查看商品数据
|
SELECT o.id, 4, 'AirPods Pro 2', '/images/default-product.svg', 1299.00, 1, 1299.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110004'
|
||||||
SELECT 'Products:' as table_name;
|
UNION ALL
|
||||||
SELECT id, name, price, stock, status
|
SELECT o.id, 1, 'iPhone 15 Pro Max', '/images/iphone15.svg', 9999.00, 1, 9999.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005'
|
||||||
FROM products
|
UNION ALL
|
||||||
ORDER BY id
|
SELECT o.id, 9, '深入理解Java虚拟机', '/images/default-product.svg', 89.00, 2, 178.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005'
|
||||||
LIMIT 10;
|
UNION ALL
|
||||||
|
SELECT o.id, 10, '五常大米 10kg', '/images/default-product.svg', 168.00, 1, 168.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005'
|
||||||
-- 查看秒杀活动数据
|
UNION ALL
|
||||||
SELECT 'Flash Sales:' as table_name;
|
SELECT o.id, 4, 'AirPods Pro 2', '/images/default-product.svg', 1899.00, 1, 1899.00, o.created_at FROM orders o WHERE o.order_no = 'ORD202603110005';
|
||||||
SELECT fs.id, p.name as product_name, fs.flash_price, fs.flash_stock, fs.start_time, fs.end_time, fs.status
|
|
||||||
FROM flash_sales fs
|
|
||||||
JOIN products p ON fs.product_id = p.id
|
|
||||||
ORDER BY fs.id;
|
|
||||||
|
|
||||||
-- 查看订单数据
|
|
||||||
SELECT 'Orders:' as table_name;
|
|
||||||
SELECT o.id, u.username, p.name as product_name, o.quantity, o.total_price, o.status, o.order_type
|
|
||||||
FROM orders o
|
|
||||||
JOIN users u ON o.user_id = u.id
|
|
||||||
JOIN products p ON o.product_id = p.id
|
|
||||||
ORDER BY o.id;
|
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 6. 统计信息
|
-- 7. 评价
|
||||||
-- ================================
|
-- ================================
|
||||||
|
INSERT INTO product_reviews (product_id, user_id, order_id, rating, content, status, admin_reply, replied_at, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(9, (SELECT id FROM users WHERE username = 'demo1'), (SELECT id FROM orders WHERE order_no = 'ORD202603110001'), 5, '内容很扎实,适合深入学习 JVM。', 1, '感谢支持,后续会持续补充相关图书。', NOW(), DATE_SUB(NOW(), INTERVAL 1 DAY), NOW()),
|
||||||
|
(4, (SELECT id FROM users WHERE username = 'demo1'), (SELECT id FROM orders WHERE order_no = 'ORD202603110002'), 4, '耳机效果不错,降噪很明显。', 1, NULL, NULL, DATE_SUB(NOW(), INTERVAL 12 HOUR), DATE_SUB(NOW(), INTERVAL 12 HOUR));
|
||||||
|
|
||||||
SELECT 'Statistics:' as info;
|
-- ================================
|
||||||
SELECT (SELECT COUNT(*) FROM users) as total_users,
|
-- 8. 收藏
|
||||||
(SELECT COUNT(*) FROM products) as total_products,
|
-- ================================
|
||||||
(SELECT COUNT(*) FROM flash_sales) as total_flash_sales,
|
INSERT INTO user_favorites (user_id, product_id, created_at)
|
||||||
(SELECT COUNT(*) FROM orders) as total_orders,
|
VALUES
|
||||||
(SELECT COUNT(*) FROM flash_sales WHERE status = 2) as active_flash_sales,
|
((SELECT id FROM users WHERE username = 'demo1'), 1, NOW()),
|
||||||
(SELECT COUNT(*) FROM orders WHERE status = 1) as pending_orders;
|
((SELECT id FROM users WHERE username = 'demo1'), 4, NOW()),
|
||||||
|
((SELECT id FROM users WHERE username = 'demo2'), 2, NOW()),
|
||||||
|
((SELECT id FROM users WHERE username = 'testuser1'), 9, NOW());
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
-- 更新演示账号密码为BCrypt格式
|
|
||||||
-- 这些是使用BCryptPasswordEncoder生成的正确哈希值
|
|
||||||
|
|
||||||
USE flash_sale_db;
|
|
||||||
|
|
||||||
-- 删除现有演示用户(如果存在)
|
|
||||||
DELETE
|
|
||||||
FROM users
|
|
||||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
|
||||||
|
|
||||||
-- 插入使用BCrypt加密的演示用户
|
|
||||||
-- demo1/demo2 密码: 123456 (BCrypt哈希)
|
|
||||||
-- admin 密码: admin123 (BCrypt哈希)
|
|
||||||
INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at)
|
|
||||||
VALUES ('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', 'USER', 1,
|
|
||||||
NOW(), NOW()),
|
|
||||||
('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1,
|
|
||||||
NOW(), NOW());
|
|
||||||
|
|
||||||
-- 验证插入结果
|
|
||||||
SELECT id,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
phone,
|
|
||||||
status,
|
|
||||||
SUBSTRING(password, 1, 30) as password_hash_preview,
|
|
||||||
created_at
|
|
||||||
FROM users
|
|
||||||
WHERE username IN ('demo1', 'demo2', 'admin')
|
|
||||||
ORDER BY username;
|
|
||||||
|
|
||||||
-- 显示账号信息
|
|
||||||
SELECT '=== 演示账号信息 ===' as info;
|
|
||||||
SELECT CONCAT(username, ' / ', CASE
|
|
||||||
WHEN username = 'admin' THEN 'admin123'
|
|
||||||
ELSE '123456'
|
|
||||||
END) as '用户名/密码'
|
|
||||||
FROM users
|
|
||||||
WHERE username IN ('demo1', 'demo2', 'admin')
|
|
||||||
ORDER BY username;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,464 +0,0 @@
|
|||||||
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
|
|
||||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
|
||||||
<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %>
|
|
||||||
|
|
||||||
<c:set var="pageTitle" value="管理后台"/>
|
|
||||||
<%@ include file="../common/header.jsp" %>
|
|
||||||
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<!-- 侧边栏 -->
|
|
||||||
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
|
|
||||||
<div class="position-sticky pt-3">
|
|
||||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
|
||||||
<span>管理功能</span>
|
|
||||||
</h6>
|
|
||||||
<ul class="nav flex-column">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="${pageContext.request.contextPath}/admin">
|
|
||||||
<i class="fas fa-tachometer-alt"></i> 仪表盘
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/products">
|
|
||||||
<i class="fas fa-box"></i> 商品管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/flashsales">
|
|
||||||
<i class="fas fa-bolt"></i> 秒杀管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/orders">
|
|
||||||
<i class="fas fa-shopping-cart"></i> 订单管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/users">
|
|
||||||
<i class="fas fa-users"></i> 用户管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
|
||||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
|
||||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
|
||||||
<h1 class="h2">管理后台仪表盘</h1>
|
|
||||||
<div class="btn-toolbar mb-2 mb-md-0">
|
|
||||||
<div class="btn-group me-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshData()">
|
|
||||||
<i class="fas fa-sync-alt"></i> 刷新数据
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
|
||||||
<div class="card border-left-primary shadow h-100 py-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row no-gutters align-items-center">
|
|
||||||
<div class="col mr-2">
|
|
||||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
|
||||||
总用户数
|
|
||||||
</div>
|
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="totalUsers">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<i class="fas fa-users fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
|
||||||
<div class="card border-left-success shadow h-100 py-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row no-gutters align-items-center">
|
|
||||||
<div class="col mr-2">
|
|
||||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
|
||||||
总商品数
|
|
||||||
</div>
|
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="totalProducts">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<i class="fas fa-box fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
|
||||||
<div class="card border-left-info shadow h-100 py-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row no-gutters align-items-center">
|
|
||||||
<div class="col mr-2">
|
|
||||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
|
||||||
活跃秒杀
|
|
||||||
</div>
|
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="activeFlashSales">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<i class="fas fa-bolt fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
|
||||||
<div class="card border-left-warning shadow h-100 py-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row no-gutters align-items-center">
|
|
||||||
<div class="col mr-2">
|
|
||||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
|
||||||
今日订单
|
|
||||||
</div>
|
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="todayOrders">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<i class="fas fa-shopping-cart fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 快速操作 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-rocket"></i> 快速操作</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<a href="${pageContext.request.contextPath}/admin/products"
|
|
||||||
class="btn btn-primary btn-block">
|
|
||||||
<i class="fas fa-plus"></i> 添加商品
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<a href="${pageContext.request.contextPath}/admin/flashsales"
|
|
||||||
class="btn btn-success btn-block">
|
|
||||||
<i class="fas fa-bolt"></i> 创建秒杀
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<a href="${pageContext.request.contextPath}/admin/orders"
|
|
||||||
class="btn btn-info btn-block">
|
|
||||||
<i class="fas fa-list"></i> 查看订单
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<a href="${pageContext.request.contextPath}/admin/monitor"
|
|
||||||
class="btn btn-warning btn-block">
|
|
||||||
<i class="fas fa-chart-line"></i> 系统监控
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 最近活动 -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-clock"></i> 最近订单</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>订单号</th>
|
|
||||||
<th>用户</th>
|
|
||||||
<th>商品</th>
|
|
||||||
<th>金额</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>时间</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="recentOrders">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-fire"></i> 热门商品</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="hotProducts">
|
|
||||||
<div class="text-center">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 56px;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 48px 0 0;
|
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-link {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-link.active {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-left-primary {
|
|
||||||
border-left: 0.25rem solid #4e73df !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-left-success {
|
|
||||||
border-left: 0.25rem solid #1cc88a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-left-info {
|
|
||||||
border-left: 0.25rem solid #36b9cc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-left-warning {
|
|
||||||
border-left: 0.25rem solid #f6c23e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-xs {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
margin-left: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
main {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
$(document).ready(function () {
|
|
||||||
loadDashboardData();
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadDashboardData() {
|
|
||||||
// 加载统计数据
|
|
||||||
loadStatistics();
|
|
||||||
|
|
||||||
// 加载最近订单
|
|
||||||
loadRecentOrders();
|
|
||||||
|
|
||||||
// 加载热门商品
|
|
||||||
loadHotProducts();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStatistics() {
|
|
||||||
// 调用真实API获取统计数据
|
|
||||||
$.get('${pageContext.request.contextPath}/api/admin/dashboard/stats')
|
|
||||||
.done(function (response) {
|
|
||||||
if (response.success) {
|
|
||||||
updateDashboardStats(response.data);
|
|
||||||
} else {
|
|
||||||
console.error('获取仪表盘数据失败:', response.message);
|
|
||||||
// 显示默认值
|
|
||||||
updateDashboardStats({});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail(function () {
|
|
||||||
console.error('获取仪表盘数据请求失败');
|
|
||||||
// 显示默认值
|
|
||||||
updateDashboardStats({});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新仪表盘统计数据
|
|
||||||
function updateDashboardStats(stats) {
|
|
||||||
$('#totalUsers').text(formatNumber(stats.totalUsers || 0));
|
|
||||||
$('#totalProducts').text(formatNumber(stats.totalProducts || 0));
|
|
||||||
$('#activeFlashSales').text(formatNumber(stats.activeFlashSales || 0));
|
|
||||||
$('#todayOrders').text(formatNumber(stats.todayOrders || 0));
|
|
||||||
|
|
||||||
// 更新订单统计卡片
|
|
||||||
$('#totalOrders').text(formatNumber(stats.totalOrders || 0));
|
|
||||||
$('#paidOrders').text(formatNumber(stats.paidOrders || 0));
|
|
||||||
$('#pendingOrders').text(formatNumber(stats.pendingOrders || 0));
|
|
||||||
$('#totalAmount').text('¥' + formatNumber(stats.totalAmount || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRecentOrders() {
|
|
||||||
// 调用真实API获取最近订单
|
|
||||||
$.get('${pageContext.request.contextPath}/api/admin/orders/recent?limit=10')
|
|
||||||
.done(function (response) {
|
|
||||||
if (response.success) {
|
|
||||||
updateRecentOrders(response.data);
|
|
||||||
} else {
|
|
||||||
console.error('获取最近订单失败:', response.message);
|
|
||||||
$('#recentOrders').html('<tr><td colspan="6" class="text-center">获取订单数据失败</td></tr>');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail(function () {
|
|
||||||
console.error('获取最近订单请求失败');
|
|
||||||
$('#recentOrders').html('<tr><td colspan="6" class="text-center">网络请求失败</td></tr>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新最近订单列表
|
|
||||||
function updateRecentOrders(orders) {
|
|
||||||
let html = '';
|
|
||||||
if (orders && orders.length > 0) {
|
|
||||||
orders.forEach(function (order) {
|
|
||||||
let statusClass = getOrderStatusClass(order.status);
|
|
||||||
let statusText = getOrderStatusText(order.status);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<tr>
|
|
||||||
<td>` + order.id + `</td>
|
|
||||||
<td>` + order.username + `</td>
|
|
||||||
<td>` + order.productName + `</td>
|
|
||||||
<td>¥` + formatNumber(order.totalAmount) + `</td>
|
|
||||||
<td><span class="badge ` + statusClass + `">` + statusText + `</span></td>
|
|
||||||
<td>` + formatDateTime(order.createdAt) + `</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
html = '<tr><td colspan="6" class="text-center">暂无订单数据</td></tr>';
|
|
||||||
}
|
|
||||||
$('#recentOrders').html(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadHotProducts() {
|
|
||||||
// 模拟数据,实际应该调用API
|
|
||||||
setTimeout(function () {
|
|
||||||
const products = [
|
|
||||||
{name: 'iPhone 15 Pro Max', sales: 156},
|
|
||||||
{name: 'MacBook Pro 16英寸', sales: 89},
|
|
||||||
{name: 'iPad Air', sales: 67},
|
|
||||||
{name: 'AirPods Pro 2', sales: 234}
|
|
||||||
];
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
products.forEach((product, index) => {
|
|
||||||
html += `
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<div>
|
|
||||||
<small class="text-muted">#${index + 1}</small>
|
|
||||||
<span class="ms-2">${product.name}</span>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-success">${product.sales}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#hotProducts').html(html);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshData() {
|
|
||||||
// 显示加载状态
|
|
||||||
$('#totalUsers, #totalProducts, #activeFlashSales, #todayOrders').html('<i class="fas fa-spinner fa-spin"></i>');
|
|
||||||
$('#recentOrders').html('<tr><td colspan="6" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
|
||||||
$('#hotProducts').html('<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>');
|
|
||||||
|
|
||||||
// 重新加载数据
|
|
||||||
loadDashboardData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
function formatNumber(num) {
|
|
||||||
if (num === null || num === undefined) return '0';
|
|
||||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateTime) {
|
|
||||||
if (!dateTime) return '';
|
|
||||||
return new Date(dateTime).toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderStatusClass(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return 'bg-warning'; // 待支付
|
|
||||||
case 2:
|
|
||||||
return 'bg-success'; // 已支付
|
|
||||||
case 3:
|
|
||||||
return 'bg-info'; // 已发货
|
|
||||||
case 4:
|
|
||||||
return 'bg-primary'; // 已完成
|
|
||||||
case 5:
|
|
||||||
return 'bg-danger'; // 已取消
|
|
||||||
default:
|
|
||||||
return 'bg-secondary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderStatusText(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return '待支付';
|
|
||||||
case 2:
|
|
||||||
return '已支付';
|
|
||||||
case 3:
|
|
||||||
return '已发货';
|
|
||||||
case 4:
|
|
||||||
return '已完成';
|
|
||||||
case 5:
|
|
||||||
return '已取消';
|
|
||||||
default:
|
|
||||||
return '未知';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%@ include file="../common/footer.jsp" %>
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
|
|
||||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>系统监控 - 管理后台</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<style>
|
|
||||||
.monitor-card {
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.metric-item {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.metric-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.metric-label {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.status-indicator {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
.status-online { background-color: #28a745; }
|
|
||||||
.status-warning { background-color: #ffc107; }
|
|
||||||
.status-offline { background-color: #dc3545; }
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
.log-container {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.log-line {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
.log-error { color: #dc3545; }
|
|
||||||
.log-warn { color: #ffc107; }
|
|
||||||
.log-info { color: #17a2b8; }
|
|
||||||
.log-debug { color: #6c757d; }
|
|
||||||
.refresh-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="/admin">
|
|
||||||
<i class="fas fa-tachometer-alt me-2"></i>管理后台
|
|
||||||
</a>
|
|
||||||
<div class="navbar-nav ms-auto">
|
|
||||||
<a class="nav-link" href="/admin">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i>返回首页
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-fluid mt-4">
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col">
|
|
||||||
<h2>
|
|
||||||
<i class="fas fa-chart-line me-2"></i>系统监控
|
|
||||||
<button class="btn btn-outline-primary btn-sm ms-3 refresh-btn" onclick="refreshAll()">
|
|
||||||
<i class="fas fa-sync-alt"></i> 刷新
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 系统状态卡片 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="card monitor-card bg-primary text-white">
|
|
||||||
<div class="card-body metric-item">
|
|
||||||
<div class="metric-value" id="cpu-usage">0%</div>
|
|
||||||
<div class="metric-label">CPU 使用率</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="card monitor-card bg-info text-white">
|
|
||||||
<div class="card-body metric-item">
|
|
||||||
<div class="metric-value" id="memory-usage">0%</div>
|
|
||||||
<div class="metric-label">内存使用率</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="card monitor-card bg-success text-white">
|
|
||||||
<div class="card-body metric-item">
|
|
||||||
<div class="metric-value" id="active-users">0</div>
|
|
||||||
<div class="metric-label">在线用户</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="card monitor-card bg-warning text-white">
|
|
||||||
<div class="card-body metric-item">
|
|
||||||
<div class="metric-value" id="total-requests">0</div>
|
|
||||||
<div class="metric-label">今日请求</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 服务状态 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card monitor-card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-server me-2"></i>服务状态
|
|
||||||
</h5>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" onclick="checkServices()">
|
|
||||||
<i class="fas fa-check"></i> 检查
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="list-group list-group-flush" id="service-status">
|
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<span class="status-indicator status-online"></span>
|
|
||||||
应用服务
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-success rounded-pill">运行中</span>
|
|
||||||
</div>
|
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<span class="status-indicator" id="redis-indicator"></span>
|
|
||||||
Redis 服务
|
|
||||||
</div>
|
|
||||||
<span class="badge rounded-pill" id="redis-status">检查中...</span>
|
|
||||||
</div>
|
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<span class="status-indicator" id="mysql-indicator"></span>
|
|
||||||
MySQL 服务
|
|
||||||
</div>
|
|
||||||
<span class="badge rounded-pill" id="mysql-status">检查中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card monitor-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-chart-area me-2"></i>系统性能趋势
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="performance-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 业务监控 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card monitor-card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-bolt me-2"></i>秒杀活动监控
|
|
||||||
</h5>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-outline-primary" onclick="loadFlashSaleStats()">
|
|
||||||
<i class="fas fa-sync-alt"></i> 刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="flashsale-monitor">
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
|
||||||
<p class="mt-3 text-muted">加载中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card monitor-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-exclamation-triangle me-2"></i>系统告警
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="system-alerts">
|
|
||||||
<div class="alert alert-success" role="alert">
|
|
||||||
<i class="fas fa-check-circle me-2"></i>
|
|
||||||
系统运行正常
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 实时日志 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card monitor-card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-file-alt me-2"></i>实时日志
|
|
||||||
</h5>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-outline-secondary" onclick="clearLogs()">
|
|
||||||
<i class="fas fa-trash"></i> 清空
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-primary" onclick="toggleAutoRefresh()">
|
|
||||||
<i class="fas fa-play" id="auto-refresh-icon"></i>
|
|
||||||
<span id="auto-refresh-text">自动刷新</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="log-container" id="log-container">
|
|
||||||
<div class="log-line log-info">[INFO] 系统监控页面已加载</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
|
||||||
<script>
|
|
||||||
let performanceChart;
|
|
||||||
let autoRefreshInterval;
|
|
||||||
let isAutoRefreshEnabled = false;
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
|
||||||
initPerformanceChart();
|
|
||||||
loadSystemMetrics();
|
|
||||||
checkServices();
|
|
||||||
loadFlashSaleStats();
|
|
||||||
loadSystemLogs();
|
|
||||||
});
|
|
||||||
|
|
||||||
function initPerformanceChart() {
|
|
||||||
const ctx = document.getElementById('performance-chart').getContext('2d');
|
|
||||||
performanceChart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: 'CPU使用率',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(75, 192, 192)',
|
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
|
||||||
tension: 0.1
|
|
||||||
}, {
|
|
||||||
label: '内存使用率',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(255, 99, 132)',
|
|
||||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
|
||||||
tension: 0.1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
max: 100,
|
|
||||||
ticks: {
|
|
||||||
callback: function(value) {
|
|
||||||
return value + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'top',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSystemMetrics() {
|
|
||||||
$.get('/api/admin/system/metrics', function(data) {
|
|
||||||
$('#cpu-usage').text(data.cpuUsage + '%');
|
|
||||||
$('#memory-usage').text(data.memoryUsage + '%');
|
|
||||||
$('#active-users').text(data.activeUsers);
|
|
||||||
$('#total-requests').text(data.totalRequests.toLocaleString());
|
|
||||||
|
|
||||||
// 更新图表
|
|
||||||
updatePerformanceChart(data.cpuUsage, data.memoryUsage);
|
|
||||||
|
|
||||||
addLogEntry('info', '系统指标已更新');
|
|
||||||
}).fail(function() {
|
|
||||||
// 模拟数据
|
|
||||||
const mockData = {
|
|
||||||
cpuUsage: Math.floor(Math.random() * 80) + 10,
|
|
||||||
memoryUsage: Math.floor(Math.random() * 70) + 20,
|
|
||||||
activeUsers: Math.floor(Math.random() * 100) + 50,
|
|
||||||
totalRequests: Math.floor(Math.random() * 10000) + 5000
|
|
||||||
};
|
|
||||||
|
|
||||||
$('#cpu-usage').text(mockData.cpuUsage + '%');
|
|
||||||
$('#memory-usage').text(mockData.memoryUsage + '%');
|
|
||||||
$('#active-users').text(mockData.activeUsers);
|
|
||||||
$('#total-requests').text(mockData.totalRequests.toLocaleString());
|
|
||||||
|
|
||||||
updatePerformanceChart(mockData.cpuUsage, mockData.memoryUsage);
|
|
||||||
|
|
||||||
addLogEntry('warn', '无法连接到监控API,显示模拟数据');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePerformanceChart(cpuUsage, memoryUsage) {
|
|
||||||
const now = new Date().toLocaleTimeString();
|
|
||||||
|
|
||||||
performanceChart.data.labels.push(now);
|
|
||||||
performanceChart.data.datasets[0].data.push(cpuUsage);
|
|
||||||
performanceChart.data.datasets[1].data.push(memoryUsage);
|
|
||||||
|
|
||||||
// 保持最近20个数据点
|
|
||||||
if (performanceChart.data.labels.length > 20) {
|
|
||||||
performanceChart.data.labels.shift();
|
|
||||||
performanceChart.data.datasets[0].data.shift();
|
|
||||||
performanceChart.data.datasets[1].data.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
performanceChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkServices() {
|
|
||||||
// 检查Redis服务
|
|
||||||
$.get('/api/admin/health/redis').done(function() {
|
|
||||||
updateServiceStatus('redis', true);
|
|
||||||
}).fail(function() {
|
|
||||||
updateServiceStatus('redis', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 检查MySQL服务
|
|
||||||
$.get('/api/admin/health/mysql').done(function() {
|
|
||||||
updateServiceStatus('mysql', true);
|
|
||||||
}).fail(function() {
|
|
||||||
updateServiceStatus('mysql', false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServiceStatus(service, isOnline) {
|
|
||||||
const indicator = $('#' + service + '-indicator');
|
|
||||||
const status = $('#' + service + '-status');
|
|
||||||
|
|
||||||
if (isOnline) {
|
|
||||||
indicator.removeClass('status-warning status-offline').addClass('status-online');
|
|
||||||
status.removeClass('bg-warning bg-danger').addClass('bg-success').text('运行中');
|
|
||||||
addLogEntry('info', service.toUpperCase() + ' 服务状态正常');
|
|
||||||
} else {
|
|
||||||
indicator.removeClass('status-online status-warning').addClass('status-offline');
|
|
||||||
status.removeClass('bg-success bg-warning').addClass('bg-danger').text('离线');
|
|
||||||
addLogEntry('error', service.toUpperCase() + ' 服务连接失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFlashSaleStats() {
|
|
||||||
$.get('/api/admin/flashsale/monitor', function(data) {
|
|
||||||
let html = '<div class="row">';
|
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
data.forEach(function(flashSale) {
|
|
||||||
const progressPercentage = Math.max(0, (flashSale.flashStock - flashSale.remainingStock) / flashSale.flashStock * 100);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">${flashSale.productName}</h6>
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<small>已售:${flashSale.flashStock - flashSale.remainingStock}</small>
|
|
||||||
<small>剩余:${flashSale.remainingStock}</small>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar ${progressPercentage > 80 ? 'bg-warning' : 'bg-success'}"
|
|
||||||
style="width: ${progressPercentage}%"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted mt-1 d-block">状态: ${flashSale.statusDescription}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
html += '<div class="col-12"><p class="text-muted text-center">暂无活跃的秒杀活动</p></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
$('#flashsale-monitor').html(html);
|
|
||||||
|
|
||||||
addLogEntry('info', '秒杀活动监控数据已更新');
|
|
||||||
}).fail(function() {
|
|
||||||
$('#flashsale-monitor').html('<div class="text-center py-4"><p class="text-danger">加载失败</p></div>');
|
|
||||||
addLogEntry('error', '加载秒杀监控数据失败');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSystemLogs() {
|
|
||||||
// 模拟实时日志
|
|
||||||
const logTypes = ['info', 'warn', 'error', 'debug'];
|
|
||||||
const logMessages = [
|
|
||||||
'用户登录成功',
|
|
||||||
'秒杀活动开始',
|
|
||||||
'Redis连接池满',
|
|
||||||
'数据库查询耗时较长',
|
|
||||||
'缓存命中率下降',
|
|
||||||
'用户注册完成',
|
|
||||||
'订单支付成功',
|
|
||||||
'库存更新完成'
|
|
||||||
];
|
|
||||||
|
|
||||||
// 添加一些初始日志
|
|
||||||
setTimeout(() => {
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const type = logTypes[Math.floor(Math.random() * logTypes.length)];
|
|
||||||
const message = logMessages[Math.floor(Math.random() * logMessages.length)];
|
|
||||||
addLogEntry(type, message);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLogEntry(level, message) {
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
const logLine = `<div class="log-line log-${level}">[${timestamp}] [${level.toUpperCase()}] ${message}</div>`;
|
|
||||||
|
|
||||||
$('#log-container').prepend(logLine);
|
|
||||||
|
|
||||||
// 保持最多100条日志
|
|
||||||
const logLines = $('#log-container .log-line');
|
|
||||||
if (logLines.length > 100) {
|
|
||||||
logLines.slice(100).remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshAll() {
|
|
||||||
loadSystemMetrics();
|
|
||||||
checkServices();
|
|
||||||
loadFlashSaleStats();
|
|
||||||
addLogEntry('info', '手动刷新所有监控数据');
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLogs() {
|
|
||||||
$('#log-container').empty();
|
|
||||||
addLogEntry('info', '日志已清空');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
|
||||||
if (isAutoRefreshEnabled) {
|
|
||||||
clearInterval(autoRefreshInterval);
|
|
||||||
$('#auto-refresh-icon').removeClass('fa-stop').addClass('fa-play');
|
|
||||||
$('#auto-refresh-text').text('自动刷新');
|
|
||||||
isAutoRefreshEnabled = false;
|
|
||||||
addLogEntry('info', '自动刷新已停止');
|
|
||||||
} else {
|
|
||||||
autoRefreshInterval = setInterval(function() {
|
|
||||||
loadSystemMetrics();
|
|
||||||
checkServices();
|
|
||||||
loadFlashSaleStats();
|
|
||||||
}, 10000); // 每10秒刷新
|
|
||||||
|
|
||||||
$('#auto-refresh-icon').removeClass('fa-play').addClass('fa-stop');
|
|
||||||
$('#auto-refresh-text').text('停止刷新');
|
|
||||||
isAutoRefreshEnabled = true;
|
|
||||||
addLogEntry('info', '自动刷新已启动');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面离开时清理定时器
|
|
||||||
$(window).on('beforeunload', function() {
|
|
||||||
if (autoRefreshInterval) {
|
|
||||||
clearInterval(autoRefreshInterval);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
|
|
||||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
|
||||||
<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %>
|
|
||||||
|
|
||||||
<c:set var="pageTitle" value="订单管理"/>
|
|
||||||
<%@ include file="../common/header.jsp" %>
|
|
||||||
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<!-- 侧边栏 -->
|
|
||||||
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
|
|
||||||
<div class="position-sticky pt-3">
|
|
||||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
|
||||||
<span>管理功能</span>
|
|
||||||
</h6>
|
|
||||||
<ul class="nav flex-column">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin">
|
|
||||||
<i class="fas fa-tachometer-alt"></i> 仪表盘
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/products">
|
|
||||||
<i class="fas fa-box"></i> 商品管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/flashsales">
|
|
||||||
<i class="fas fa-bolt"></i> 秒杀管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="${pageContext.request.contextPath}/admin/orders">
|
|
||||||
<i class="fas fa-shopping-cart"></i> 订单管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/users">
|
|
||||||
<i class="fas fa-users"></i> 用户管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
|
||||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
|
||||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
|
||||||
<h1 class="h2">订单管理</h1>
|
|
||||||
<div class="btn-toolbar mb-2 mb-md-0">
|
|
||||||
<div class="btn-group me-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshOrders()">
|
|
||||||
<i class="fas fa-sync-alt"></i> 刷新
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-success" onclick="exportOrders()">
|
|
||||||
<i class="fas fa-download"></i> 导出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 筛选和搜索 -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="searchInput" placeholder="搜索订单号/用户...">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="searchOrders()">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<select class="form-select" id="statusFilter" onchange="filterOrders()">
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="pending">待支付</option>
|
|
||||||
<option value="paid">已支付</option>
|
|
||||||
<option value="shipped">已发货</option>
|
|
||||||
<option value="completed">已完成</option>
|
|
||||||
<option value="cancelled">已取消</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<input type="date" class="form-control" id="dateFilter" onchange="filterOrders()">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<select class="form-select" id="sortBy" onchange="sortOrders()">
|
|
||||||
<option value="created_at">按创建时间</option>
|
|
||||||
<option value="total_amount">按订单金额</option>
|
|
||||||
<option value="status">按订单状态</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 订单统计 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-primary" id="totalOrders">0</h5>
|
|
||||||
<p class="card-text">总订单数</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-success" id="paidOrders">0</h5>
|
|
||||||
<p class="card-text">已支付订单</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-warning" id="pendingOrders">0</h5>
|
|
||||||
<p class="card-text">待处理订单</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-info" id="totalAmount">¥0</h5>
|
|
||||||
<p class="card-text">总交易额</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 订单列表 -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>订单号</th>
|
|
||||||
<th>用户</th>
|
|
||||||
<th>商品信息</th>
|
|
||||||
<th>数量</th>
|
|
||||||
<th>总金额</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>创建时间</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="ordersTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="text-center">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<nav aria-label="订单分页">
|
|
||||||
<ul class="pagination justify-content-center" id="pagination">
|
|
||||||
<!-- 分页按钮将通过JavaScript生成 -->
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 订单详情模态框 -->
|
|
||||||
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">订单详情</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="orderDetailContent">
|
|
||||||
<!-- 订单详情内容 -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 56px;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 48px 0 0;
|
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-link {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-link.active {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
margin-left: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
main {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentPage = 1;
|
|
||||||
let pageSize = 10;
|
|
||||||
let totalPages = 1;
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
|
||||||
loadOrders();
|
|
||||||
loadOrderStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadOrders(page = 1) {
|
|
||||||
currentPage = page;
|
|
||||||
|
|
||||||
// 显示加载状态
|
|
||||||
$('#orderTableBody').html('<tr><td colspan="8" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
|
||||||
|
|
||||||
// 构建请求参数
|
|
||||||
let params = {
|
|
||||||
page: page,
|
|
||||||
size: 10
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyword = $('#searchKeyword').val();
|
|
||||||
const status = $('#statusFilter').val();
|
|
||||||
|
|
||||||
if (keyword && keyword.trim()) {
|
|
||||||
params.keyword = keyword.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status && status !== '') {
|
|
||||||
params.status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用真实API
|
|
||||||
$.get('${pageContext.request.contextPath}/api/admin/orders', params)
|
|
||||||
.done(function (response) {
|
|
||||||
if (response.success) {
|
|
||||||
renderOrdersTable(response.data.orders);
|
|
||||||
renderPagination(response.data.currentPage, response.data.totalPages);
|
|
||||||
} else {
|
|
||||||
$('#orderTableBody').html('<tr><td colspan="8" class="text-center text-danger">获取订单数据失败: ' + response.message + '</td></tr>');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail(function () {
|
|
||||||
$('#orderTableBody').html('<tr><td colspan="8" class="text-center text-danger">网络请求失败,请稍后重试</td></tr>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadOrderStats() {
|
|
||||||
// 调用真实API获取订单统计数据
|
|
||||||
$.get('${pageContext.request.contextPath}/api/admin/orders/stats')
|
|
||||||
.done(function (response) {
|
|
||||||
if (response.success) {
|
|
||||||
updateOrderStats(response.data);
|
|
||||||
} else {
|
|
||||||
console.error('获取订单统计数据失败:', response.message);
|
|
||||||
// 显示默认值
|
|
||||||
updateOrderStats({});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail(function () {
|
|
||||||
console.error('获取订单统计数据请求失败');
|
|
||||||
// 显示默认值
|
|
||||||
updateOrderStats({});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新订单统计数据
|
|
||||||
function updateOrderStats(stats) {
|
|
||||||
$('#totalOrders').text(formatNumber(stats.totalOrders || 0));
|
|
||||||
$('#paidOrders').text(formatNumber(stats.paidOrders || 0));
|
|
||||||
$('#pendingOrders').text(formatNumber(stats.pendingOrders || 0));
|
|
||||||
$('#totalAmount').text('¥' + formatNumber(stats.totalAmount || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOrdersTable(orders) {
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
if (orders.length === 0) {
|
|
||||||
html = '<tr><td colspan="8" class="text-center">暂无订单数据</td></tr>';
|
|
||||||
} else {
|
|
||||||
orders.forEach(order => {
|
|
||||||
const statusText = getOrderStatusText(order.status);
|
|
||||||
const statusClass = getOrderStatusClass(order.status);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">` + order.id + `</div>
|
|
||||||
` + (order.isFlashSale ? '<small class="text-danger"><i class="fas fa-bolt"></i> 秒杀订单</small>' : '') + `
|
|
||||||
</td>
|
|
||||||
<td>` + order.username + `</td>
|
|
||||||
<td>` + order.productName + `</td>
|
|
||||||
<td>` + order.quantity + `</td>
|
|
||||||
<td class="fw-bold">¥` + formatNumber(order.totalAmount || 0) + `</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge ` + statusClass + `">
|
|
||||||
` + statusText + `
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>` + formatDateTime(order.createdAt) + `</td>
|
|
||||||
<td>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-outline-primary" onclick="viewOrderDetail('` + order.id + `')" title="查看详情">
|
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
</button>
|
|
||||||
` + (order.status === 2 ?
|
|
||||||
'<button class="btn btn-outline-success" onclick="shipOrder(\'' + order.id + '\')" title="发货"><i class="fas fa-shipping-fast"></i></button>' : '') + `
|
|
||||||
` + (order.status === 1 ?
|
|
||||||
'<button class="btn btn-outline-danger" onclick="cancelOrder(\'' + order.id + '\')" title="取消"><i class="fas fa-times"></i></button>' : '') + `
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#ordersTableBody').html(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderStatusText(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'pending':
|
|
||||||
return '待支付';
|
|
||||||
case 'paid':
|
|
||||||
return '已支付';
|
|
||||||
case 'shipped':
|
|
||||||
return '已发货';
|
|
||||||
case 'completed':
|
|
||||||
return '已完成';
|
|
||||||
case 'cancelled':
|
|
||||||
return '已取消';
|
|
||||||
default:
|
|
||||||
return '未知';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderStatusClass(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'pending':
|
|
||||||
return 'bg-warning';
|
|
||||||
case 'paid':
|
|
||||||
return 'bg-success';
|
|
||||||
case 'shipped':
|
|
||||||
return 'bg-info';
|
|
||||||
case 'completed':
|
|
||||||
return 'bg-primary';
|
|
||||||
case 'cancelled':
|
|
||||||
return 'bg-secondary';
|
|
||||||
default:
|
|
||||||
return 'bg-light';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPagination(total, pageSize) {
|
|
||||||
totalPages = Math.ceil(total / pageSize);
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
// 上一页
|
|
||||||
html += `
|
|
||||||
<li class="page-item ` + (currentPage === 1 ? 'disabled' : '') + `">
|
|
||||||
<a class="page-link" href="#" onclick="loadOrders(` + (currentPage - 1) + `)">上一页</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 页码
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
html += `
|
|
||||||
<li class="page-item ` + (i === currentPage ? 'active' : '') + `">
|
|
||||||
<a class="page-link" href="#" onclick="loadOrders(` + i + `)">` + i + `</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下一页
|
|
||||||
html += `
|
|
||||||
<li class="page-item ` + (currentPage === totalPages ? 'disabled' : '') + `">
|
|
||||||
<a class="page-link" href="#" onclick="loadOrders(` + (currentPage + 1) + `)">下一页</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
|
|
||||||
$('#pagination').html(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshOrders() {
|
|
||||||
$('#ordersTableBody').html('<tr><td colspan="8" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
|
||||||
loadOrders(currentPage);
|
|
||||||
loadOrderStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchOrders() {
|
|
||||||
const keyword = $('#searchInput').val();
|
|
||||||
console.log('搜索订单:', keyword);
|
|
||||||
loadOrders(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOrders() {
|
|
||||||
const status = $('#statusFilter').val();
|
|
||||||
const date = $('#dateFilter').val();
|
|
||||||
console.log('筛选订单:', {status, date});
|
|
||||||
loadOrders(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortOrders() {
|
|
||||||
const sortBy = $('#sortBy').val();
|
|
||||||
console.log('排序方式:', sortBy);
|
|
||||||
loadOrders(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewOrderDetail(orderId) {
|
|
||||||
console.log('查看订单详情:', orderId);
|
|
||||||
|
|
||||||
// 模拟获取订单详情
|
|
||||||
const orderDetail = `
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6>订单信息</h6>
|
|
||||||
<table class="table table-sm">
|
|
||||||
<tr><td>订单号:</td><td>` + orderId + `</td></tr>
|
|
||||||
<tr><td>用户:</td><td>demo1</td></tr>
|
|
||||||
<tr><td>状态:</td><td><span class="badge bg-success">已支付</span></td></tr>
|
|
||||||
<tr><td>创建时间:</td><td>2025-06-29 10:30:15</td></tr>
|
|
||||||
<tr><td>支付时间:</td><td>2025-06-29 10:31:20</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6>商品信息</h6>
|
|
||||||
<table class="table table-sm">
|
|
||||||
<tr><td>商品名称:</td><td>iPhone 15 Pro Max</td></tr>
|
|
||||||
<tr><td>单价:</td><td>¥8,888.00</td></tr>
|
|
||||||
<tr><td>数量:</td><td>1</td></tr>
|
|
||||||
<tr><td>总金额:</td><td class="fw-bold">¥8,888.00</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6>收货地址</h6>
|
|
||||||
<p>北京市朝阳区xxx街道xxx号xxx小区xxx楼xxx室<br>
|
|
||||||
收货人: 张三 13800138001</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
$('#orderDetailContent').html(orderDetail);
|
|
||||||
$('#orderDetailModal').modal('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
function shipOrder(orderId) {
|
|
||||||
if (confirm('确定要将此订单标记为已发货吗?')) {
|
|
||||||
console.log('发货订单:', orderId);
|
|
||||||
|
|
||||||
setTimeout(function () {
|
|
||||||
alert('订单已标记为已发货!');
|
|
||||||
refreshOrders();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelOrder(orderId) {
|
|
||||||
if (confirm('确定要取消此订单吗?此操作不可恢复。')) {
|
|
||||||
console.log('取消订单:', orderId);
|
|
||||||
|
|
||||||
setTimeout(function () {
|
|
||||||
alert('订单已取消!');
|
|
||||||
refreshOrders();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportOrders() {
|
|
||||||
console.log('导出订单数据');
|
|
||||||
alert('订单数据导出功能开发中...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
function formatNumber(num) {
|
|
||||||
if (num === null || num === undefined) return '0';
|
|
||||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateTime) {
|
|
||||||
if (!dateTime) return '';
|
|
||||||
return new Date(dateTime).toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderStatusClass(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return 'bg-warning'; // 待支付
|
|
||||||
case 2:
|
|
||||||
return 'bg-success'; // 已支付
|
|
||||||
case 3:
|
|
||||||
return 'bg-info'; // 已发货
|
|
||||||
case 4:
|
|
||||||
return 'bg-primary'; // 已完成
|
|
||||||
case 5:
|
|
||||||
return 'bg-danger'; // 已取消
|
|
||||||
default:
|
|
||||||
return 'bg-secondary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderStatusText(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return '待支付';
|
|
||||||
case 2:
|
|
||||||
return '已支付';
|
|
||||||
case 3:
|
|
||||||
return '已发货';
|
|
||||||
case 4:
|
|
||||||
return '已完成';
|
|
||||||
case 5:
|
|
||||||
return '已取消';
|
|
||||||
default:
|
|
||||||
return '未知';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%@ include file="../common/footer.jsp" %>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,404 +0,0 @@
|
|||||||
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
|
|
||||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
|
||||||
<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %>
|
|
||||||
|
|
||||||
<c:set var="pageTitle" value="用户管理"/>
|
|
||||||
<%@ include file="../common/header.jsp" %>
|
|
||||||
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<!-- 侧边栏 -->
|
|
||||||
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
|
|
||||||
<div class="position-sticky pt-3">
|
|
||||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
|
||||||
<span>管理功能</span>
|
|
||||||
</h6>
|
|
||||||
<ul class="nav flex-column">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin">
|
|
||||||
<i class="fas fa-tachometer-alt"></i> 仪表盘
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/products">
|
|
||||||
<i class="fas fa-box"></i> 商品管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/flashsales">
|
|
||||||
<i class="fas fa-bolt"></i> 秒杀管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/orders">
|
|
||||||
<i class="fas fa-shopping-cart"></i> 订单管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="${pageContext.request.contextPath}/admin/users">
|
|
||||||
<i class="fas fa-users"></i> 用户管理
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
|
||||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
|
||||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
|
||||||
<h1 class="h2">用户管理</h1>
|
|
||||||
<div class="btn-toolbar mb-2 mb-md-0">
|
|
||||||
<div class="btn-group me-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshUsers()">
|
|
||||||
<i class="fas fa-sync-alt"></i> 刷新
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-success" onclick="exportUsers()">
|
|
||||||
<i class="fas fa-download"></i> 导出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 搜索和筛选 -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="searchInput" placeholder="搜索用户名/邮箱/手机...">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="searchUsers()">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<select class="form-select" id="statusFilter" onchange="filterUsers()">
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="1">正常</option>
|
|
||||||
<option value="0">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<input type="date" class="form-control" id="dateFilter" onchange="filterUsers()">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<select class="form-select" id="sortBy" onchange="sortUsers()">
|
|
||||||
<option value="created_at">按注册时间</option>
|
|
||||||
<option value="username">按用户名</option>
|
|
||||||
<option value="last_login">按最后登录</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 用户统计 -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-primary" id="totalUsers">0</h5>
|
|
||||||
<p class="card-text">总用户数</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-success" id="activeUsers">0</h5>
|
|
||||||
<p class="card-text">活跃用户</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-warning" id="newUsers">0</h5>
|
|
||||||
<p class="card-text">今日新增</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-info" id="onlineUsers">0</h5>
|
|
||||||
<p class="card-text">在线用户</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 用户列表 -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>用户名</th>
|
|
||||||
<th>邮箱</th>
|
|
||||||
<th>手机号</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>注册时间</th>
|
|
||||||
<th>最后登录</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="usersTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="text-center">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<nav aria-label="用户分页">
|
|
||||||
<ul class="pagination justify-content-center" id="pagination">
|
|
||||||
<!-- 分页按钮将通过JavaScript生成 -->
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 56px;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 48px 0 0;
|
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-link {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-link.active {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
margin-left: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
main {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentPage = 1;
|
|
||||||
let pageSize = 10;
|
|
||||||
let totalPages = 1;
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
|
||||||
loadUsers();
|
|
||||||
loadUserStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadUsers(page = 1) {
|
|
||||||
currentPage = page;
|
|
||||||
|
|
||||||
// 显示加载状态
|
|
||||||
$('#userTableBody').html('<tr><td colspan="7" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
|
||||||
|
|
||||||
// 构建请求参数
|
|
||||||
let params = {
|
|
||||||
page: page,
|
|
||||||
size: 10
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyword = $('#searchKeyword').val();
|
|
||||||
const status = $('#statusFilter').val();
|
|
||||||
|
|
||||||
if (keyword && keyword.trim()) {
|
|
||||||
params.keyword = keyword.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status && status !== '') {
|
|
||||||
params.status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用真实API
|
|
||||||
$.get('${pageContext.request.contextPath}/api/admin/users', params)
|
|
||||||
.done(function (response) {
|
|
||||||
if (response.success) {
|
|
||||||
renderUsersTable(response.data.users);
|
|
||||||
renderPagination(response.data.currentPage, response.data.totalPages);
|
|
||||||
} else {
|
|
||||||
$('#userTableBody').html('<tr><td colspan="7" class="text-center text-danger">获取用户数据失败: ' + response.message + '</td></tr>');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail(function () {
|
|
||||||
$('#userTableBody').html('<tr><td colspan="7" class="text-center text-danger">网络请求失败,请稍后重试</td></tr>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadUserStats() {
|
|
||||||
// 调用真实API获取用户统计数据
|
|
||||||
$.get('${pageContext.request.contextPath}/api/admin/users/stats')
|
|
||||||
.done(function (response) {
|
|
||||||
if (response.success) {
|
|
||||||
updateUserStats(response.data);
|
|
||||||
} else {
|
|
||||||
console.error('获取用户统计数据失败:', response.message);
|
|
||||||
// 显示默认值
|
|
||||||
updateUserStats({});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail(function () {
|
|
||||||
console.error('获取用户统计数据请求失败');
|
|
||||||
// 显示默认值
|
|
||||||
updateUserStats({});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新用户统计数据
|
|
||||||
function updateUserStats(stats) {
|
|
||||||
$('#totalUsers').text(formatNumber(stats.totalUsers || 0));
|
|
||||||
$('#activeUsers').text(formatNumber(stats.activeUsers || 0));
|
|
||||||
$('#newUsers').text(formatNumber(stats.newUsers || 0));
|
|
||||||
$('#onlineUsers').text(formatNumber(stats.onlineUsers || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsersTable(users) {
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
html = '<tr><td colspan="7" class="text-center">暂无用户数据</td></tr>';
|
|
||||||
} else {
|
|
||||||
users.forEach(user => {
|
|
||||||
html += `
|
|
||||||
<tr>
|
|
||||||
<td>` + user.id + `</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="me-2">` + user.username + `</span>
|
|
||||||
` + (user.isOnline ? '<span class="badge bg-success">在线</span>' : '') + `
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>` + (user.email || '-') + `</td>
|
|
||||||
<td>` + (user.phone || '-') + `</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge ` + getUserStatusClass(user.status) + `">
|
|
||||||
` + getUserStatusText(user.status) + `
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>` + formatDateTime(user.createdAt) + `</td>
|
|
||||||
<td>` + (user.lastLogin ? formatDateTime(user.lastLogin) : '从未登录') + `</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#usersTableBody').html(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPagination(total, pageSize) {
|
|
||||||
totalPages = Math.ceil(total / pageSize);
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
// 上一页
|
|
||||||
html += `
|
|
||||||
<li class="page-item ` + (currentPage === 1 ? 'disabled' : '') + `">
|
|
||||||
<a class="page-link" href="#" onclick="loadUsers(` + (currentPage - 1) + `)">上一页</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 页码
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
html += `
|
|
||||||
<li class="page-item ` + (i === currentPage ? 'active' : '') + `">
|
|
||||||
<a class="page-link" href="#" onclick="loadUsers(` + i + `)">` + i + `</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下一页
|
|
||||||
html += `
|
|
||||||
<li class="page-item ` + (currentPage === totalPages ? 'disabled' : '') + `">
|
|
||||||
<a class="page-link" href="#" onclick="loadUsers(` + (currentPage + 1) + `)">下一页</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
|
|
||||||
$('#pagination').html(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshUsers() {
|
|
||||||
$('#usersTableBody').html('<tr><td colspan="7" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
|
||||||
loadUsers(currentPage);
|
|
||||||
loadUserStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchUsers() {
|
|
||||||
const keyword = $('#searchInput').val();
|
|
||||||
console.log('搜索用户:', keyword);
|
|
||||||
loadUsers(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterUsers() {
|
|
||||||
const status = $('#statusFilter').val();
|
|
||||||
const date = $('#dateFilter').val();
|
|
||||||
console.log('筛选用户:', {status, date});
|
|
||||||
loadUsers(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortUsers() {
|
|
||||||
const sortBy = $('#sortBy').val();
|
|
||||||
console.log('排序方式:', sortBy);
|
|
||||||
loadUsers(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function exportUsers() {
|
|
||||||
console.log('导出用户数据');
|
|
||||||
alert('用户数据导出功能开发中...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
function formatNumber(num) {
|
|
||||||
if (num === null || num === undefined) return '0';
|
|
||||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateTime) {
|
|
||||||
if (!dateTime) return '';
|
|
||||||
return new Date(dateTime).toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserStatusClass(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return 'bg-success'; // 正常
|
|
||||||
case 0:
|
|
||||||
return 'bg-danger'; // 禁用
|
|
||||||
default:
|
|
||||||
return 'bg-secondary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserStatusText(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return '正常';
|
|
||||||
case 0:
|
|
||||||
return '禁用';
|
|
||||||
default:
|
|
||||||
return '未知';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%@ include file="../common/footer.jsp" %>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user