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:
2026-03-14 16:40:26 +08:00
parent b684ea38d4
commit c4582655d9
115 changed files with 5968 additions and 12623 deletions

View File

@@ -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架构设计

View File

@@ -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 集群**

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,6 @@ onMounted(() => {
<style> <style>
#app { #app {
min-height: 100vh; min-height: 100vh;
background-color: #f5f5f5; background: transparent;
} }
</style> </style>

View File

@@ -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', {

View 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')
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
} }
// 动画 // 动画

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [],
} }

View File

@@ -31,6 +31,13 @@ export default defineConfig({
}, },
}, },
}, },
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
},
},
},
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {

View File

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

View File

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

View File

@@ -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);
}
}
/** /**
* 获取秒杀活动详情 * 获取秒杀活动详情
*/ */

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
/** /**
* 更新秒杀活动状态 * 更新秒杀活动状态
*/ */

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
/** /**
* 根据创建时间范围统计订单数量 * 根据创建时间范围统计订单数量

View File

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

View File

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

View File

@@ -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); // 待支付

View File

@@ -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 "未知";
}
}
}

View File

@@ -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值
*/ */

View File

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

View File

@@ -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 "未知类型";
} }

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ server:
port: 8080 port: 8080
servlet: servlet:
context-path: / context-path: /
session:
timeout: 30m
spring: spring:
application: application:

View 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

View File

@@ -1,22 +1,17 @@
-- 演示账号快速创建脚本 -- 演示账号初始化脚本
-- 密码都是明文对应的值demo1/demo2/admin的密码分别是123456/123456/admin123 -- 账号demo1 / 123456demo2 / 123456admin / 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');

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

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