feat: 删除JSP视图层,完善评价和通知系统,新增拼团模块
- 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA - 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API - 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API - 新增拼团模块:GroupBuying 全套后端和前端页面 - 修复 review check API 参数双重包装导致请求格式错误 - 修复通知 API 路径缺少 /api 前缀和响应格式处理 - MessageListenerService 集成 NotificationService 创建持久化通知 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,11 +66,11 @@ mysql -u root -p -e "CREATE DATABASE flash_sale_db CHARACTER SET utf8mb4 COLLATE
|
||||
# 导入表结构(JPA自动创建,可选)
|
||||
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/test-data.sql
|
||||
```
|
||||
|
||||
## Redis架构设计
|
||||
|
||||
15
README.md
15
README.md
@@ -89,11 +89,9 @@ FlashSaleSystem/
|
||||
│ │ ├── rate_limit.lua # 限流脚本
|
||||
│ │ └── unlock.lua # 解锁脚本
|
||||
│ ├── sql/ # SQL脚本
|
||||
│ │ ├── demo-users.sql # 演示用户数据
|
||||
│ │ ├── fix-demo-users.sql # 修复用户数据
|
||||
│ │ ├── schema.sql # 数据库架构
|
||||
│ │ ├── test-data.sql # 测试数据
|
||||
│ │ └── update-passwords.sql # 更新密码
|
||||
│ │ ├── demo-users.sql # 演示账号
|
||||
│ │ ├── schema.sql # 数据库结构
|
||||
│ │ └── test-data.sql # 测试业务数据
|
||||
│ └── static/images/ # 静态图片资源
|
||||
└── src/main/webapp/WEB-INF/views/ # JSP页面
|
||||
├── admin/ # 管理员页面
|
||||
@@ -228,13 +226,14 @@ cd FlashSaleSystem
|
||||
```bash
|
||||
# 创建数据库
|
||||
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 集群**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 开发环境配置
|
||||
VITE_APP_TITLE=秒杀系统
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_BASE_URL=
|
||||
VITE_WS_URL=ws://localhost:8080/ws
|
||||
VITE_UPLOAD_URL=http://localhost:8080/upload
|
||||
VITE_TIMEOUT=10000
|
||||
|
||||
64
flash-sale-frontend/package-lock.json
generated
64
flash-sale-frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^20.11.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
@@ -1142,6 +1143,22 @@
|
||||
"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": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.7",
|
||||
@@ -4221,6 +4238,53 @@
|
||||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
"format": "prettier --write src/",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.15",
|
||||
@@ -43,6 +46,7 @@
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"prettier": "^3.2.4",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0"
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@playwright/test": "^1.52.0"
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,6 @@ onMounted(() => {
|
||||
<style>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,11 @@ const flashSaleSortField = (sort?: string) => {
|
||||
}
|
||||
|
||||
export const flashsaleApi = {
|
||||
// 获取秒杀活动统计信息(即将开始/正在进行/我的参与/抢购成功)
|
||||
getStatistics(): Promise<ApiResponse<{ upcoming: number; active: number; participated: number; success: number }>> {
|
||||
return request.get('/api/flashsale/statistics')
|
||||
},
|
||||
|
||||
// 获取秒杀活动列表
|
||||
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<FlashSale>>> {
|
||||
return request.post<ApiResponse<Record<string, any>>>('/api/flashsale/list', {
|
||||
|
||||
108
flash-sale-frontend/src/api/modules/groupbuying.ts
Normal file
108
flash-sale-frontend/src/api/modules/groupbuying.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { request } from '../request'
|
||||
import type { ApiResponse, GroupBuying, GroupBuyingGroup, GroupBuyingStatistics, PageParams, PageResponse } from '@/types/api'
|
||||
import { normalizeGroupBuying, normalizeGroupBuyingGroup, normalizePage } from '@/utils/normalizers'
|
||||
|
||||
const groupBuyingStatusToCode = (status?: string) => {
|
||||
if (status === 'DRAFT') return 0
|
||||
if (status === 'UPCOMING') return 1
|
||||
if (status === 'ACTIVE') return 2
|
||||
if (status === 'ENDED') return 3
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const groupbuyingApi = {
|
||||
getStatistics(): Promise<ApiResponse<GroupBuyingStatistics>> {
|
||||
return request.get('/api/groupbuying/statistics')
|
||||
},
|
||||
|
||||
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<GroupBuying>>> {
|
||||
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/list', {
|
||||
status: groupBuyingStatusToCode(params?.status),
|
||||
page: params?.page ?? 0,
|
||||
size: params?.size ?? 10,
|
||||
}).then((res) => ({
|
||||
...res,
|
||||
data: normalizePage(res.data, normalizeGroupBuying),
|
||||
}))
|
||||
},
|
||||
|
||||
getDetail(id: number): Promise<ApiResponse<GroupBuying>> {
|
||||
return request.get<ApiResponse<any>>(`/api/groupbuying/${id}`).then((res) => ({
|
||||
...res,
|
||||
data: normalizeGroupBuying(res.data),
|
||||
}))
|
||||
},
|
||||
|
||||
getGroups(id: number, params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
|
||||
return request.get<ApiResponse<Record<string, any>>>(`/api/groupbuying/${id}/groups`, {
|
||||
page: params?.page ?? 0,
|
||||
size: params?.size ?? 10,
|
||||
}).then((res) => ({
|
||||
...res,
|
||||
data: normalizePage(res.data, normalizeGroupBuyingGroup),
|
||||
}))
|
||||
},
|
||||
|
||||
joinGroup(data: { groupBuyingId: number; groupId?: number }): Promise<ApiResponse<{
|
||||
success: boolean
|
||||
message: string
|
||||
groupId: number
|
||||
groupNo: string
|
||||
orderId: number
|
||||
}>> {
|
||||
return request.post('/api/groupbuying/join', data)
|
||||
},
|
||||
|
||||
getGroupDetail(groupId: number): Promise<ApiResponse<GroupBuyingGroup>> {
|
||||
return request.get<ApiResponse<any>>(`/api/groupbuying/group/${groupId}`).then((res) => ({
|
||||
...res,
|
||||
data: normalizeGroupBuyingGroup(res.data),
|
||||
}))
|
||||
},
|
||||
|
||||
cancelMembership(groupId: number): Promise<ApiResponse> {
|
||||
return request.post(`/api/groupbuying/group/${groupId}/cancel`)
|
||||
},
|
||||
|
||||
getMyGroups(params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
|
||||
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/my-groups', {
|
||||
page: params?.page ?? 0,
|
||||
size: params?.size ?? 10,
|
||||
}).then((res) => ({
|
||||
...res,
|
||||
data: normalizePage(res.data, normalizeGroupBuyingGroup),
|
||||
}))
|
||||
},
|
||||
|
||||
// Admin
|
||||
create(data: {
|
||||
productId: number
|
||||
groupPrice: number
|
||||
requiredMembers: number
|
||||
durationMinutes: number
|
||||
totalStock: number
|
||||
maxPerUser: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
}): Promise<ApiResponse<GroupBuying>> {
|
||||
return request.post<ApiResponse<any>>('/api/groupbuying/admin/create', data).then((res) => ({
|
||||
...res,
|
||||
data: normalizeGroupBuying(res.data),
|
||||
}))
|
||||
},
|
||||
|
||||
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<GroupBuying>> {
|
||||
return request.put<ApiResponse<any>>(`/api/groupbuying/admin/${id}`, data).then((res) => ({
|
||||
...res,
|
||||
data: normalizeGroupBuying(res.data),
|
||||
}))
|
||||
},
|
||||
|
||||
delete(id: number): Promise<ApiResponse> {
|
||||
return request.delete(`/api/groupbuying/admin/${id}`)
|
||||
},
|
||||
|
||||
preloadAll(): Promise<ApiResponse> {
|
||||
return request.post('/api/groupbuying/admin/preload-all')
|
||||
},
|
||||
}
|
||||
45
flash-sale-frontend/src/api/modules/notification.ts
Normal file
45
flash-sale-frontend/src/api/modules/notification.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { request } from '../request'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
userId: number
|
||||
type: 'flashsale' | 'order' | 'system'
|
||||
title: string
|
||||
message: string
|
||||
link?: string
|
||||
read: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ApiRes<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
/** 获取通知列表 */
|
||||
getList(type?: string): Promise<ApiRes<NotificationItem[]>> {
|
||||
return request.get('/api/notification/list', type ? { type } : undefined)
|
||||
},
|
||||
|
||||
/** 获取未读数量 */
|
||||
getUnreadCount(): Promise<ApiRes<number>> {
|
||||
return request.get('/api/notification/unread-count')
|
||||
},
|
||||
|
||||
/** 标记单条已读 */
|
||||
markAsRead(id: number): Promise<ApiRes> {
|
||||
return request.put(`/api/notification/${id}/read`)
|
||||
},
|
||||
|
||||
/** 全部标记已读 */
|
||||
markAllAsRead(): Promise<ApiRes> {
|
||||
return request.put('/api/notification/read-all')
|
||||
},
|
||||
|
||||
/** 清空所有通知 */
|
||||
clearAll(): Promise<ApiRes> {
|
||||
return request.delete('/api/notification/clear')
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ export interface ReviewItem {
|
||||
userId: number
|
||||
orderId: number
|
||||
username: string
|
||||
productName?: string
|
||||
productImage?: string
|
||||
rating: number
|
||||
content: string
|
||||
adminReply?: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
@@ -19,6 +22,11 @@ export interface ReviewSummary {
|
||||
reviews: ReviewItem[]
|
||||
}
|
||||
|
||||
export interface ReviewCheckResult {
|
||||
reviewed: boolean
|
||||
review?: ReviewItem
|
||||
}
|
||||
|
||||
export const reviewApi = {
|
||||
getProductReviews(productId: number): Promise<ApiResponse<ReviewSummary>> {
|
||||
return request.get(`/api/review/product/${productId}`)
|
||||
@@ -27,4 +35,16 @@ export const reviewApi = {
|
||||
create(data: { orderId: number; productId: number; rating: number; content: string }): Promise<ApiResponse<ReviewItem>> {
|
||||
return request.post('/api/review', data)
|
||||
},
|
||||
|
||||
checkReview(orderId: number, productId: number): Promise<ApiResponse<ReviewCheckResult>> {
|
||||
return request.get('/api/review/check', { orderId, productId })
|
||||
},
|
||||
|
||||
getMyReviews(): Promise<ApiResponse<ReviewItem[]>> {
|
||||
return request.get('/api/review/my')
|
||||
},
|
||||
|
||||
getOrderReviews(orderId: number): Promise<ApiResponse<ReviewItem[]>> {
|
||||
return request.get(`/api/review/order/${orderId}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import request from './request'
|
||||
import type { Product, ProductParams } from '@/types/product'
|
||||
|
||||
export const productApi = {
|
||||
// 获取商品列表
|
||||
getList(params?: ProductParams) {
|
||||
return request.get<any, { list: Product[], total: number }>('/api/products', { params })
|
||||
},
|
||||
|
||||
// 获取商品详情
|
||||
getDetail(id: number) {
|
||||
return request.get<any, Product>(`/api/products/${id}`)
|
||||
},
|
||||
|
||||
// 获取热门商品
|
||||
getHot(limit: number = 8) {
|
||||
return request.get<any, Product[]>('/api/products/hot', {
|
||||
params: { limit }
|
||||
})
|
||||
},
|
||||
|
||||
// 获取推荐商品
|
||||
getRecommended(limit: number = 8) {
|
||||
return request.get<any, Product[]>('/api/products/recommended', {
|
||||
params: { limit }
|
||||
})
|
||||
},
|
||||
|
||||
// 搜索商品
|
||||
search(keyword: string) {
|
||||
return request.get<any, Product[]>('/api/products/search', {
|
||||
params: { keyword }
|
||||
})
|
||||
},
|
||||
|
||||
// 按分类获取商品
|
||||
getByCategory(categoryId: number) {
|
||||
return request.get<any, Product[]>('/api/products/category', {
|
||||
params: { categoryId }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default productApi
|
||||
@@ -10,7 +10,8 @@ import router from '@/router'
|
||||
|
||||
// 创建axios实例
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="countdown-timer">
|
||||
<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="separator">:</span>
|
||||
<span class="time-block">{{ minutes.toString().padStart(2, '0') }}</span>
|
||||
@@ -60,12 +60,20 @@ onUnmounted(() => {
|
||||
.countdown-timer {
|
||||
@apply flex items-center justify-center text-lg font-mono;
|
||||
|
||||
.countdown-icon {
|
||||
color: #5e5e58;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@apply mx-1 text-red-500 font-bold;
|
||||
@apply mx-1 font-bold;
|
||||
color: #5e5e58;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
|
||||
<div class="flex items-end mb-3">
|
||||
<span class="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>
|
||||
</div>
|
||||
<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 class="text-sm text-gray-400">已结束</span>
|
||||
</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>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
@@ -76,7 +76,7 @@ const statusText = computed(() => {
|
||||
|
||||
const discountPercent = computed(() => Math.round((1 - props.data.flashPrice / props.data.originalPrice) * 100))
|
||||
const stockPercent = computed(() => props.data.flashStock === 0 ? 0 : Math.round(props.data.remainingStock / props.data.flashStock * 100))
|
||||
const progressColor = computed(() => stockPercent.value > 50 ? '#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 canParticipate = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
|
||||
const buttonText = computed(() => {
|
||||
@@ -96,7 +96,8 @@ const handleParticipate = async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flash-sale-card {
|
||||
@apply bg-white rounded-lg overflow-hidden;
|
||||
@apply bg-white rounded-2xl overflow-hidden;
|
||||
background: #fffaf2;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
@@ -104,7 +105,15 @@ const handleParticipate = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.flash-price {
|
||||
@apply text-2xl font-bold;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
118
flash-sale-frontend/src/components/business/GroupBuyingCard.vue
Normal file
118
flash-sale-frontend/src/components/business/GroupBuyingCard.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="group-buying-card card-shadow" @click="$router.push(`/groupbuying/${data.id}`)">
|
||||
<div class="relative cursor-pointer">
|
||||
<SafeImage
|
||||
:src="data.productImageUrl"
|
||||
:alt="data.productName"
|
||||
wrapper-class="w-full h-48"
|
||||
img-class="w-full h-48 object-cover"
|
||||
/>
|
||||
|
||||
<div class="absolute top-2 left-2">
|
||||
<el-tag :type="statusType" effect="dark" size="small">
|
||||
<el-icon class="mr-1"><Connection /></el-icon>
|
||||
{{ statusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="discount-badge">省 ¥{{ data.discount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
|
||||
<div class="flex items-end mb-2">
|
||||
<span class="group-price">¥{{ data.groupPrice }}</span>
|
||||
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.productPrice }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<el-icon class="mr-1"><User /></el-icon>
|
||||
<span>{{ data.requiredMembers }}人团</span>
|
||||
<span class="mx-2">|</span>
|
||||
<span>剩余 {{ data.remainingStock }} 件</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<el-progress :percentage="stockPercent" :stroke-width="6" :show-text="false" :color="progressColor" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-3">
|
||||
<span v-if="data.activeGroupCount > 0">{{ data.activeGroupCount }} 个团进行中</span>
|
||||
<span v-else>暂无进行中的团</span>
|
||||
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')" />
|
||||
</div>
|
||||
<el-button type="primary" class="w-full" :disabled="!canJoin" @click.stop="handleJoin">
|
||||
<el-icon class="mr-1"><Connection /></el-icon>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { GroupBuying } from '@/types/api'
|
||||
import CountDown from './CountDown.vue'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const props = defineProps<{ data: GroupBuying }>()
|
||||
const emit = defineEmits<{ join: [id: number]; refresh: [] }>()
|
||||
|
||||
const statusType = computed(() => {
|
||||
switch (props.data.status) {
|
||||
case 'UPCOMING': return 'warning'
|
||||
case 'ACTIVE': return 'success'
|
||||
case 'ENDED': return 'info'
|
||||
default: return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.data.status) {
|
||||
case 'DRAFT': return '草稿'
|
||||
case 'UPCOMING': return '即将开始'
|
||||
case 'ACTIVE': return '拼团中'
|
||||
case 'ENDED': return '已结束'
|
||||
default: return '未知'
|
||||
}
|
||||
})
|
||||
|
||||
const stockPercent = computed(() => props.data.totalStock === 0 ? 0 : Math.round(props.data.remainingStock / props.data.totalStock * 100))
|
||||
const progressColor = computed(() => (stockPercent.value > 50 ? '#171715' : stockPercent.value > 20 ? '#5e5e58' : '#9f9f99'))
|
||||
const endTime = computed(() => new Date(props.data.endTime).getTime())
|
||||
const canJoin = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
|
||||
const buttonText = computed(() => {
|
||||
if (props.data.status === 'UPCOMING') return '即将开始'
|
||||
if (props.data.status === 'ENDED') return '已结束'
|
||||
if (props.data.remainingStock === 0) return '已售罄'
|
||||
return '去拼团'
|
||||
})
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!canJoin.value) return
|
||||
emit('join', props.data.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.group-buying-card {
|
||||
@apply bg-white rounded-2xl overflow-hidden;
|
||||
background: #fffaf2;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.group-price {
|
||||
@apply text-2xl font-bold;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
@apply px-2 py-1 text-xs font-bold rounded;
|
||||
background: #fffaf2;
|
||||
color: #171715;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="group-member-list">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div v-for="member in members" :key="member.userId" class="member-avatar" :title="member.username">
|
||||
<el-avatar :size="40" :src="member.avatar">
|
||||
{{ member.username ? member.username[0] : '?' }}
|
||||
</el-avatar>
|
||||
<span class="member-name">{{ member.username }}</span>
|
||||
<el-tag v-if="member.userId === leaderUserId" size="small" type="warning" class="leader-tag">团长</el-tag>
|
||||
</div>
|
||||
<div v-for="i in emptySlots" :key="'empty-' + i" class="member-avatar empty">
|
||||
<div class="empty-slot">
|
||||
<el-icon :size="20"><Plus /></el-icon>
|
||||
</div>
|
||||
<span class="member-name text-gray-400">等待加入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { GroupBuyingMember } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
members: GroupBuyingMember[]
|
||||
requiredMembers: number
|
||||
leaderUserId?: number
|
||||
}>()
|
||||
|
||||
const emptySlots = computed(() => Math.max(0, props.requiredMembers - props.members.length))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-avatar {
|
||||
@apply flex flex-col items-center gap-1;
|
||||
|
||||
.member-name {
|
||||
@apply text-xs text-gray-600 truncate;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.leader-tag {
|
||||
@apply mt-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
@apply w-10 h-10 rounded-full border-2 border-dashed border-gray-300 flex items-center justify-center text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ data.description || '暂无描述' }}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -59,7 +59,8 @@ const handleViewDetail = () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-card {
|
||||
@apply bg-white rounded-lg overflow-hidden;
|
||||
@apply bg-white rounded-2xl overflow-hidden;
|
||||
background: #fffaf2;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
@@ -67,6 +68,11 @@ const handleViewDetail = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
@apply text-xl font-bold;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
181
flash-sale-frontend/src/components/business/ReviewDialog.vue
Normal file
181
flash-sale-frontend/src/components/business/ReviewDialog.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="商品评价"
|
||||
width="640px"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
>
|
||||
<div v-if="checkLoading" class="text-center py-8">
|
||||
<el-icon :size="32" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载评价状态...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div v-if="reviewableItems.length === 0 && reviewedItems.length === 0" class="text-center py-8">
|
||||
<el-empty description="暂无可评价商品" />
|
||||
</div>
|
||||
|
||||
<!-- 待评价商品 -->
|
||||
<div v-for="item in reviewableItems" :key="item.productId" class="border rounded-lg p-4">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-16 h-16 rounded" img-class="w-16 h-16 object-cover rounded" />
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||
<div class="text-sm text-gray-500">¥{{ item.price }} × {{ item.quantity }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm text-gray-600 mb-1">评分</label>
|
||||
<el-rate v-model="item.rating" show-text :texts="['很差', '较差', '一般', '满意', '非常满意']" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">评价内容</label>
|
||||
<el-input
|
||||
v-model="item.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="分享一下你的使用感受吧"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已评价商品 -->
|
||||
<div v-for="item in reviewedItems" :key="'reviewed-' + item.productId" class="border rounded-lg p-4 bg-gray-50">
|
||||
<div class="flex gap-4">
|
||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-16 h-16 rounded" img-class="w-16 h-16 object-cover rounded" />
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||
<el-tag type="success" size="small">已评价</el-tag>
|
||||
</div>
|
||||
<el-rate :model-value="item.existingReview!.rating" disabled />
|
||||
<p class="text-sm text-gray-600 mt-1">{{ item.existingReview!.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
||||
<el-button
|
||||
v-if="reviewableItems.length > 0"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交评价
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import type { ReviewItem } from '@/api/modules/review'
|
||||
import type { OrderItem } from '@/types/api'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
interface ReviewableItem extends OrderItem {
|
||||
rating: number
|
||||
content: string
|
||||
reviewed: boolean
|
||||
existingReview?: ReviewItem
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
orderId: number
|
||||
orderItems: OrderItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const checkLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const items = ref<ReviewableItem[]>([])
|
||||
|
||||
const reviewableItems = computed(() => items.value.filter(i => !i.reviewed))
|
||||
const reviewedItems = computed(() => items.value.filter(i => i.reviewed))
|
||||
const canSubmit = computed(() => reviewableItems.value.some(i => i.content.trim()))
|
||||
|
||||
const loadReviewStatus = async () => {
|
||||
if (!props.orderId || !props.orderItems.length) return
|
||||
checkLoading.value = true
|
||||
try {
|
||||
const list: ReviewableItem[] = props.orderItems.map(item => ({
|
||||
...item,
|
||||
rating: 5,
|
||||
content: '',
|
||||
reviewed: false,
|
||||
existingReview: undefined,
|
||||
}))
|
||||
|
||||
const checks = await Promise.all(
|
||||
list.map(item => reviewApi.checkReview(props.orderId, item.productId).catch(() => null))
|
||||
)
|
||||
|
||||
checks.forEach((res, index) => {
|
||||
if (res?.success && res.data.reviewed) {
|
||||
list[index].reviewed = true
|
||||
list[index].existingReview = res.data.review
|
||||
}
|
||||
})
|
||||
|
||||
items.value = list
|
||||
} finally {
|
||||
checkLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const toSubmit = reviewableItems.value.filter(i => i.content.trim())
|
||||
if (toSubmit.length === 0) {
|
||||
ElMessage.warning('请至少填写一条评价内容')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
let successCount = 0
|
||||
try {
|
||||
for (const item of toSubmit) {
|
||||
try {
|
||||
await reviewApi.create({
|
||||
orderId: props.orderId,
|
||||
productId: item.productId,
|
||||
rating: item.rating,
|
||||
content: item.content.trim(),
|
||||
})
|
||||
item.reviewed = true
|
||||
item.existingReview = { rating: item.rating, content: item.content } as ReviewItem
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
const respData = error?.response?.data
|
||||
const msg = respData?.message || error?.message || '提交失败'
|
||||
ElMessage.error(`${item.productName}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(`成功提交 ${successCount} 条评价`)
|
||||
emit('success')
|
||||
if (reviewableItems.value.length === 0) {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) loadReviewStatus()
|
||||
})
|
||||
</script>
|
||||
@@ -15,17 +15,17 @@
|
||||
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<router-link to="/" class="text-gray-600 hover:text-primary-500">
|
||||
<router-link to="/" class="footer-link">
|
||||
首页
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/flashsales" class="text-gray-600 hover:text-primary-500">
|
||||
<router-link to="/flashsales" class="footer-link">
|
||||
秒杀活动
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/products" class="text-gray-600 hover:text-primary-500">
|
||||
<router-link to="/products" class="footer-link">
|
||||
商品列表
|
||||
</router-link>
|
||||
</li>
|
||||
@@ -73,16 +73,25 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-footer {
|
||||
background: white;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-top: 1px solid #d8cebf;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #5e5e58;
|
||||
|
||||
&:hover {
|
||||
color: #171715;
|
||||
}
|
||||
}
|
||||
|
||||
.tech-tag {
|
||||
padding: 2px 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
background-color: #fffaf2;
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: #5c5346;
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,12 @@
|
||||
<nav class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<router-link to="/" class="flex items-center space-x-2">
|
||||
<el-icon :size="24" class="text-red-500">
|
||||
<router-link to="/" class="brand-link">
|
||||
<el-icon :size="24" class="brand-icon">
|
||||
<Lightning />
|
||||
</el-icon>
|
||||
<span class="text-xl font-bold">秒杀系统</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-title">秒杀系统</span>
|
||||
<span class="brand-tag">
|
||||
FLASH SALE
|
||||
</span>
|
||||
</router-link>
|
||||
@@ -25,9 +25,28 @@
|
||||
<el-icon><Lightning /></el-icon>
|
||||
秒杀活动
|
||||
</router-link>
|
||||
<el-dropdown trigger="hover" @command="handleCategoryCommand">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +58,7 @@
|
||||
<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-icon :size="20"><ShoppingCart /></el-icon>
|
||||
</el-badge>
|
||||
@@ -48,7 +67,7 @@
|
||||
<!-- 用户菜单 -->
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<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">
|
||||
{{ userStore.username[0] }}
|
||||
</el-avatar>
|
||||
@@ -68,6 +87,14 @@
|
||||
<el-icon><Star /></el-icon>
|
||||
我的收藏
|
||||
</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-icon><Setting /></el-icon>
|
||||
管理后台
|
||||
@@ -97,6 +124,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import NotificationCenter from './NotificationCenter.vue'
|
||||
import SearchComponent from './SearchComponent.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
@@ -106,6 +134,28 @@ const userStore = useUserStore()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const cartCount = ref(0)
|
||||
const categories = ref<string[]>([])
|
||||
|
||||
// 加载分类
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const res = await productApi.getCategories()
|
||||
if (res.success) {
|
||||
categories.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分类下拉菜单点击
|
||||
const handleCategoryCommand = (category: string) => {
|
||||
if (category) {
|
||||
router.push({ path: '/products', query: { category } })
|
||||
} else {
|
||||
router.push('/products')
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
@@ -126,6 +176,7 @@ const updateCartCount = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
updateCartCount()
|
||||
})
|
||||
</script>
|
||||
@@ -137,32 +188,103 @@ onMounted(() => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 250, 242, 0.92);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
color: #333;
|
||||
padding: 8px 2px;
|
||||
color: #5e5e58;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
transition: color 0.25s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
color: #171715;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -2px;
|
||||
height: 1px;
|
||||
background: #171715;
|
||||
transform: scaleX(0);
|
||||
transform-origin: center;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&.router-link-active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
:deep(.el-badge__content) {
|
||||
background-color: var(--primary-color);
|
||||
background-color: #fffaf2;
|
||||
color: #171715;
|
||||
border: 1px solid #171715;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="content">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="message">{{ item.message }}</div>
|
||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!item.read"
|
||||
@@ -71,13 +71,13 @@
|
||||
:class="{ unread: !item.read }"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<el-icon :size="16" class="text-red-500">
|
||||
<el-icon :size="16" class="notification-icon">
|
||||
<Lightning />
|
||||
</el-icon>
|
||||
<div class="content">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="message">{{ item.message }}</div>
|
||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,13 +94,13 @@
|
||||
:class="{ unread: !item.read }"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<el-icon :size="16" class="text-blue-500">
|
||||
<el-icon :size="16" class="notification-icon">
|
||||
<List />
|
||||
</el-icon>
|
||||
<div class="content">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="message">{{ item.message }}</div>
|
||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<div class="content">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="message">{{ item.message }}</div>
|
||||
<div class="time">{{ formatTime(item.timestamp) }}</div>
|
||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
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 relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
@@ -155,75 +158,47 @@ dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const router = useRouter()
|
||||
const { subscribe, unsubscribe } = useWebSocket()
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
type: 'flashsale' | 'order' | 'system'
|
||||
title: string
|
||||
message: string
|
||||
timestamp: number
|
||||
read: boolean
|
||||
link?: string
|
||||
}
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const notifications = ref<Notification[]>([
|
||||
{
|
||||
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 notifications = ref<NotificationItem[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 计算属性
|
||||
const unreadCount = computed(() =>
|
||||
notifications.value.filter(n => !n.read).length
|
||||
)
|
||||
|
||||
const allNotifications = computed(() =>
|
||||
notifications.value.slice().sort((a, b) => b.timestamp - a.timestamp)
|
||||
)
|
||||
const allNotifications = computed(() => notifications.value)
|
||||
|
||||
const flashsaleNotifications = computed(() =>
|
||||
notifications.value.filter(n => n.type === 'flashsale')
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
)
|
||||
|
||||
const orderNotifications = computed(() =>
|
||||
notifications.value.filter(n => n.type === 'order')
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
)
|
||||
|
||||
const systemNotifications = computed(() =>
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -240,34 +215,49 @@ const getIcon = (type: string) => {
|
||||
// 获取图标类名
|
||||
const getIconClass = (type: string) => {
|
||||
const classes: Record<string, string> = {
|
||||
'flashsale': 'text-red-500',
|
||||
'order': 'text-blue-500',
|
||||
'system': 'text-gray-500'
|
||||
'flashsale': 'notification-icon',
|
||||
'order': 'notification-icon',
|
||||
'system': 'notification-icon muted'
|
||||
}
|
||||
return classes[type] || 'text-gray-500'
|
||||
}
|
||||
|
||||
// 标记已读
|
||||
const markAsRead = (id: string) => {
|
||||
const notification = notifications.value.find(n => n.id === id)
|
||||
if (notification) {
|
||||
const markAsRead = async (id: number | string) => {
|
||||
const notification = notifications.value.find(n => String(n.id) === String(id))
|
||||
if (notification && !notification.read) {
|
||||
try {
|
||||
await notificationApi.markAsRead(Number(id))
|
||||
notification.read = true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全部标记已读
|
||||
const markAllAsRead = () => {
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await notificationApi.markAllAsRead()
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 清空消息
|
||||
const clearAll = () => {
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
await notificationApi.clearAll()
|
||||
notifications.value = []
|
||||
visible.value = false
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理点击
|
||||
const handleClick = (item: Notification) => {
|
||||
const handleClick = (item: NotificationItem) => {
|
||||
markAsRead(item.id)
|
||||
if (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(() => {
|
||||
// 订阅WebSocket消息
|
||||
subscribe('FLASH_SALE_START', handleFlashSaleMessage)
|
||||
subscribe('FLASH_SALE_END', handleFlashSaleMessage)
|
||||
subscribe('ORDER_STATUS', handleOrderMessage)
|
||||
subscribe('SYSTEM_NOTICE', handleSystemMessage)
|
||||
fetchNotifications()
|
||||
// 每60秒轮询一次
|
||||
pollTimer = setInterval(fetchNotifications, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 取消订阅
|
||||
unsubscribe('FLASH_SALE_START', handleFlashSaleMessage)
|
||||
unsubscribe('FLASH_SALE_END', handleFlashSaleMessage)
|
||||
unsubscribe('ORDER_STATUS', handleOrderMessage)
|
||||
unsubscribe('SYSTEM_NOTICE', handleSystemMessage)
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -334,13 +285,27 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 999px;
|
||||
background: #fffaf2;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
color: #171715;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
color: #44443f;
|
||||
|
||||
&.muted {
|
||||
color: #7b7b74;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
.notification-header {
|
||||
display: flex;
|
||||
@@ -385,14 +350,14 @@ onUnmounted(() => {
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
background-color: #f7f7f6;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background-color: #f0f9ff;
|
||||
background-color: #f7f7f6;
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,12 +394,12 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
|
||||
.view-all {
|
||||
color: var(--el-color-primary);
|
||||
color: #44443f;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
color: #171715;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ const handleClick = () => {
|
||||
.safe-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
background: #f4ede4;
|
||||
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
@@ -85,13 +85,13 @@ const handleClick = () => {
|
||||
&__placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
background: #f4ede4;
|
||||
}
|
||||
|
||||
&__shimmer {
|
||||
width: 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<el-collapse v-model="activeCollapse">
|
||||
<el-collapse-item name="advanced">
|
||||
<template #title>
|
||||
<span class="text-sm text-blue-500">
|
||||
<span class="search-advanced-title">
|
||||
<el-icon><Setting /></el-icon>
|
||||
高级搜索
|
||||
</span>
|
||||
@@ -250,7 +250,7 @@ const highlightKeyword = (text: string) => {
|
||||
if (!searchQuery.value) return text
|
||||
|
||||
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-section {
|
||||
margin-bottom: 20px;
|
||||
@@ -391,7 +399,7 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
background-color: #efefed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,7 +416,7 @@ onMounted(async () => {
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
background-color: #f7f7f6;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -428,12 +436,12 @@ onMounted(async () => {
|
||||
|
||||
.type {
|
||||
padding: 0 6px;
|
||||
background-color: #f0f0f0;
|
||||
background-color: #efefed;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #f56c6c;
|
||||
color: #2b2b27;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -444,7 +452,7 @@ onMounted(async () => {
|
||||
|
||||
.advanced-search {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
border-top: 1px solid #d8cebf;
|
||||
padding-top: 10px;
|
||||
|
||||
.advanced-form {
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
<template #title>秒杀管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/groupbuying">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<template #title>拼团管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/orders">
|
||||
<el-icon><List /></el-icon>
|
||||
<template #title>订单管理</template>
|
||||
@@ -153,6 +158,7 @@ const currentPageTitle = computed(() => {
|
||||
'/admin': '',
|
||||
'/admin/products': '商品管理',
|
||||
'/admin/flashsales': '秒杀管理',
|
||||
'/admin/groupbuying': '拼团管理',
|
||||
'/admin/orders': '订单管理',
|
||||
'/admin/users': '用户管理',
|
||||
'/admin/reviews': '评价管理',
|
||||
@@ -191,6 +197,7 @@ const handleLogout = async () => {
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
|
||||
.el-container {
|
||||
height: 100%;
|
||||
@@ -198,7 +205,8 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
background-color: #001529;
|
||||
background: #fffaf2;
|
||||
border-right: 1px solid #d8cebf;
|
||||
transition: width 0.3s;
|
||||
|
||||
.logo-container {
|
||||
@@ -207,46 +215,52 @@ const handleLogout = async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: white;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #171715;
|
||||
border-bottom: 1px solid #d8cebf;
|
||||
|
||||
.logo-icon {
|
||||
color: #ef4444;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
background-color: #001529;
|
||||
background: transparent;
|
||||
padding: 12px 10px;
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
color: #171715;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: #f4ede4 !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: white;
|
||||
background-color: #1890ff !important;
|
||||
color: #171715;
|
||||
background-color: #fffdf8 !important;
|
||||
box-shadow: inset 0 0 0 1px #171715;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
background: rgba(255, 250, 242, 0.92);
|
||||
backdrop-filter: none;
|
||||
border-bottom: 1px solid #d8cebf;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
padding: 0 24px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
@@ -258,7 +272,7 @@ const handleLogout = async () => {
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
color: #171715;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,7 +288,7 @@ const handleLogout = async () => {
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
color: #171715;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,17 +297,22 @@ const handleLogout = async () => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 999px;
|
||||
background: #fffaf2;
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #2b2b27;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
background: transparent;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
// 动画
|
||||
|
||||
@@ -27,7 +27,7 @@ import AppFooter from '@/components/common/AppFooter.vue'
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding-top: 60px; // header高度
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 路由切换动画
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="admin-dashboard">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-blue-100 text-blue-500">
|
||||
<div class="stat-icon tone-1">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -212,7 +212,7 @@ const renderSalesChart = () => {
|
||||
data: recentOrders.value.map((item) => item.totalAmount),
|
||||
itemStyle: {
|
||||
borderRadius: [6, 6, 0, 0],
|
||||
color: '#3b82f6',
|
||||
color: '#171715',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -228,6 +228,7 @@ const renderCategoryChart = () => {
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
color: ['#171715', '#5e5e58', '#9f9f99'],
|
||||
series: [
|
||||
{
|
||||
name: '商品状态',
|
||||
@@ -288,11 +289,28 @@ onUnmounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.admin-dashboard {
|
||||
.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 {
|
||||
@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 {
|
||||
@@ -308,7 +326,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -93,14 +93,10 @@ onMounted(() => { reloadData() })
|
||||
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
||||
.actions { display:flex; gap:12px; }
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
||||
.mini-stat { @apply rounded-xl text-white p-5 shadow-sm; }
|
||||
.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 { @apply rounded-xl p-5 shadow-sm; background:#fffaf2; color:#171715; border:1px solid #d8cebf; box-shadow:0 10px 24px rgba(23,22,20,0.04); }
|
||||
.mini-stat__value { @apply text-3xl font-bold; }
|
||||
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
||||
.panel-card { @apply bg-white rounded-xl 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; }
|
||||
.table-footer { @apply flex justify-end mt-4; }
|
||||
</style>
|
||||
|
||||
@@ -128,10 +128,22 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
<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 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>
|
||||
<template #footer>
|
||||
@@ -187,6 +199,9 @@ const formRef = ref<FormInstance>()
|
||||
const flashSales = ref<FlashSale[]>([])
|
||||
const currentItem = ref<FlashSale | null>(null)
|
||||
const productOptions = ref<AdminProductRow[]>([])
|
||||
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
|
||||
const CREATE_START_LEAD_MINUTES = 5
|
||||
const CREATE_DURATION_DAYS = 1
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
@@ -206,13 +221,16 @@ const stats = reactive<AdminFlashSaleStats>({
|
||||
endedFlashSales: 0,
|
||||
})
|
||||
|
||||
const buildDefaultStartTime = () => dayjs().add(CREATE_START_LEAD_MINUTES, 'minute').startOf('minute').format(TIME_FORMAT)
|
||||
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(CREATE_DURATION_DAYS, 'day').format(TIME_FORMAT)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
productId: undefined as number | undefined,
|
||||
flashPrice: 0.01,
|
||||
flashStock: 1,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
startTime: buildDefaultStartTime(),
|
||||
endTime: buildDefaultEndTime(),
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
@@ -254,13 +272,38 @@ const getStockRate = (item: FlashSale) => {
|
||||
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 = () => {
|
||||
form.id = 0
|
||||
form.productId = undefined
|
||||
form.flashPrice = 0.01
|
||||
form.flashStock = 1
|
||||
form.startTime = ''
|
||||
form.endTime = ''
|
||||
form.startTime = buildDefaultStartTime()
|
||||
form.endTime = buildDefaultEndTime(form.startTime)
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
@@ -315,6 +358,7 @@ const submitForm = async () => {
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (!validateTimeRange()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -435,19 +479,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
&.red { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.gray { background: linear-gradient(135deg, #64748b, #475569); }
|
||||
@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 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 {
|
||||
@@ -468,7 +513,7 @@ onMounted(() => {
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
@@ -499,7 +544,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.flash-price {
|
||||
@apply text-3xl font-bold text-rose-500;
|
||||
@apply text-3xl font-bold;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.origin-price {
|
||||
|
||||
485
flash-sale-frontend/src/pages/admin/groupbuying.vue
Normal file
485
flash-sale-frontend/src/pages/admin/groupbuying.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div class="admin-groupbuying page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">拼团管理</h2>
|
||||
<p class="page-subtitle">创建和管理拼团活动,查看团组详情</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button @click="reloadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建拼团
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat purple">
|
||||
<div class="mini-stat__value">{{ stats.totalActivities }}</div>
|
||||
<div class="mini-stat__label">活动总数</div>
|
||||
</div>
|
||||
<div class="mini-stat red">
|
||||
<div class="mini-stat__value">{{ stats.activeActivities }}</div>
|
||||
<div class="mini-stat__label">进行中</div>
|
||||
</div>
|
||||
<div class="mini-stat orange">
|
||||
<div class="mini-stat__value">{{ stats.myGroups }}</div>
|
||||
<div class="mini-stat__label">团组数</div>
|
||||
</div>
|
||||
<div class="mini-stat gray">
|
||||
<div class="mini-stat__value">{{ stats.successGroups }}</div>
|
||||
<div class="mini-stat__label">已成团</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||
<el-option label="草稿" value="DRAFT" />
|
||||
<el-option label="即将开始" value="UPCOMING" />
|
||||
<el-option label="进行中" value="ACTIVE" />
|
||||
<el-option label="已结束" value="ENDED" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="商品" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<SafeImage :src="row.productImageUrl" :alt="row.productName" wrapper-class="product-image" img-class="product-image" />
|
||||
<div>
|
||||
<div class="product-name">{{ row.productName }}</div>
|
||||
<div class="product-meta">商品ID:{{ row.productId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原价" width="100">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.productPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="拼团价" width="100">
|
||||
<template #default="{ row }"><span class="font-bold">¥{{ formatCurrency(row.groupPrice) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="requiredMembers" label="成团人数" width="90" />
|
||||
<el-table-column label="库存" width="120">
|
||||
<template #default="{ row }">{{ row.remainingStock }} / {{ row.totalStock }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div>{{ formatTime(row.startTime) }}</div>
|
||||
<div class="text-slate-400">至 {{ formatTime(row.endTime) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">{{ row.statusDescription }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT'" text type="success" @click="publishActivity(row)">发布</el-button>
|
||||
<el-button text type="danger" @click="removeActivity(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadList"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑拼团活动' : '创建拼团活动'" width="760px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="关联商品" prop="productId">
|
||||
<el-select v-model="form.productId" filterable :disabled="!!editingId" placeholder="请选择商品" class="w-full">
|
||||
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="拼团价格" prop="groupPrice">
|
||||
<el-input-number v-model="form.groupPrice" :min="0.01" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总库存" prop="totalStock">
|
||||
<el-input-number v-model="form.totalStock" :min="1" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="成团人数" prop="requiredMembers">
|
||||
<el-input-number v-model="form.requiredMembers" :min="2" :max="100" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="每人限购" prop="maxPerUser">
|
||||
<el-input-number v-model="form.maxPerUser" :min="1" :max="10" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="有效期(分钟)">
|
||||
<el-input-number v-model="form.durationMinutes" :min="1" :max="10080" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disablePastDate"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disablePastDate"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import type { GroupBuying, GroupBuyingStatistics } from '@/types/api'
|
||||
import type { AdminProductRow } from '@/types/admin'
|
||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const list = ref<GroupBuying[]>([])
|
||||
const productOptions = ref<AdminProductRow[]>([])
|
||||
|
||||
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
|
||||
|
||||
const stats = ref<GroupBuyingStatistics>({
|
||||
totalActivities: 0,
|
||||
activeActivities: 0,
|
||||
myGroups: 0,
|
||||
successGroups: 0,
|
||||
totalSaved: 0,
|
||||
})
|
||||
|
||||
const query = reactive({ status: '' as string })
|
||||
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
|
||||
const buildDefaultStartTime = () => dayjs().add(5, 'minute').startOf('minute').format(TIME_FORMAT)
|
||||
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(1, 'day').format(TIME_FORMAT)
|
||||
|
||||
const form = reactive({
|
||||
productId: undefined as number | undefined,
|
||||
groupPrice: 0.01,
|
||||
requiredMembers: 2,
|
||||
durationMinutes: 1440,
|
||||
totalStock: 100,
|
||||
maxPerUser: 1,
|
||||
startTime: buildDefaultStartTime(),
|
||||
endTime: buildDefaultEndTime(),
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
productId: [{ required: true, message: '请选择商品', trigger: 'change' }],
|
||||
groupPrice: [{ required: true, message: '请输入拼团价格', trigger: 'change' }],
|
||||
totalStock: [{ required: true, message: '请输入总库存', trigger: 'change' }],
|
||||
requiredMembers: [{ required: true, message: '请输入成团人数', trigger: 'change' }],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
||||
const formatTime = (value: string) => dayjs(value).format(TIME_FORMAT)
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'info'
|
||||
case 'UPCOMING': return 'warning'
|
||||
case 'ACTIVE': return 'success'
|
||||
case 'ENDED': return ''
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const disablePastDate = (date: Date) => dayjs(date).endOf('day').isBefore(dayjs())
|
||||
|
||||
const validateTimeRange = () => {
|
||||
const now = dayjs()
|
||||
const startTime = dayjs(form.startTime)
|
||||
const endTime = dayjs(form.endTime)
|
||||
|
||||
if (!startTime.isValid() || !endTime.isValid()) {
|
||||
ElMessage.error('开始时间或结束时间格式无效')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!startTime.isAfter(now)) {
|
||||
ElMessage.error('开始时间必须晚于当前时间')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!endTime.isAfter(startTime)) {
|
||||
ElMessage.error('结束时间必须晚于开始时间')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
editingId.value = null
|
||||
form.productId = undefined
|
||||
form.groupPrice = 0.01
|
||||
form.requiredMembers = 2
|
||||
form.durationMinutes = 1440
|
||||
form.totalStock = 100
|
||||
form.maxPerUser = 1
|
||||
form.startTime = buildDefaultStartTime()
|
||||
form.endTime = buildDefaultEndTime(form.startTime)
|
||||
}
|
||||
|
||||
const loadProducts = async () => {
|
||||
const res = await adminApi.getProducts({ page: 1, size: 100 })
|
||||
productOptions.value = res.data.products.filter((item) => item.status === 1)
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [listRes, statsRes] = await Promise.all([
|
||||
groupbuyingApi.getList({
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size,
|
||||
status: query.status || undefined,
|
||||
}),
|
||||
groupbuyingApi.getStatistics(),
|
||||
])
|
||||
|
||||
list.value = listRes.data.content
|
||||
pagination.total = listRes.data.totalElements
|
||||
stats.value = statsRes.data
|
||||
} catch (e) {
|
||||
console.error('加载拼团活动失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (row: GroupBuying) => {
|
||||
editingId.value = row.id
|
||||
form.productId = row.productId
|
||||
form.groupPrice = row.groupPrice
|
||||
form.requiredMembers = row.requiredMembers
|
||||
form.durationMinutes = row.durationMinutes
|
||||
form.totalStock = row.totalStock
|
||||
form.maxPerUser = row.maxPerUser
|
||||
form.startTime = dayjs(row.startTime).format(TIME_FORMAT)
|
||||
form.endTime = dayjs(row.endTime).format(TIME_FORMAT)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (!validateTimeRange()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = { ...form, productId: form.productId! }
|
||||
|
||||
if (editingId.value) {
|
||||
await groupbuyingApi.update(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await groupbuyingApi.create(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await reloadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const publishActivity = async (row: GroupBuying) => {
|
||||
await ElMessageBox.confirm(`确定要发布活动吗?`, '发布确认', { type: 'warning' })
|
||||
try {
|
||||
await groupbuyingApi.update(row.id, { status: 1 })
|
||||
ElMessage.success('已发布')
|
||||
await reloadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
const removeActivity = async (row: GroupBuying) => {
|
||||
await ElMessageBox.confirm('确定要删除该拼团活动吗?', '删除确认', { type: 'warning' })
|
||||
try {
|
||||
await groupbuyingApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await reloadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
query.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = () => {
|
||||
pagination.page = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
const reloadData = async () => {
|
||||
await Promise.all([loadProducts(), loadList()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reloadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl p-5 shadow-sm;
|
||||
background: #fffaf2;
|
||||
color: #171715;
|
||||
border: 1px solid #d8cebf;
|
||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||
|
||||
&__value { @apply text-3xl font-bold; }
|
||||
&__label { @apply text-sm opacity-90 mt-2; }
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl p-5;
|
||||
border: 1px solid #d8cebf;
|
||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 100px 100px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
@apply font-medium text-slate-900;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
@apply text-xs text-slate-400 mt-1;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
@apply flex justify-end mt-4;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -233,6 +233,7 @@ const renderChart = () => {
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
color: ['#171715', '#5e5e58', '#9f9f99'],
|
||||
legend: { top: 0 },
|
||||
grid: { left: 24, right: 24, top: 40, bottom: 24, containLabel: true },
|
||||
xAxis: { type: 'category', data: history.time },
|
||||
@@ -369,12 +370,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
@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; }
|
||||
@@ -387,7 +387,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -413,8 +415,9 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
background: #f4ede4;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
@@ -431,8 +434,8 @@ onUnmounted(() => {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.success { background: #10b981; }
|
||||
.dot.danger { background: #ef4444; }
|
||||
.dot.success { background: #171715; }
|
||||
.dot.danger { background: #666666; }
|
||||
|
||||
.chart-container {
|
||||
height: 320px;
|
||||
@@ -445,8 +448,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.business-item {
|
||||
background: #f8fafc;
|
||||
background: #f4ede4;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #d8cebf;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -475,13 +479,14 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
background: #fffaf2;
|
||||
color: #171715;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #94a3b8;
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -315,19 +315,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
@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 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 {
|
||||
@@ -367,8 +368,9 @@ onMounted(() => {
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
background: #f4ede4;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
@@ -387,7 +389,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.item-total {
|
||||
@apply text-lg font-semibold text-rose-500;
|
||||
@apply text-lg font-semibold;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
|
||||
@@ -409,12 +409,11 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.gray { background: linear-gradient(135deg, #64748b, #475569); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
@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;
|
||||
@@ -426,7 +425,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -458,7 +459,7 @@ onMounted(() => {
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid #d8cebf;
|
||||
}
|
||||
|
||||
.detail-content h3 {
|
||||
@@ -466,7 +467,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -481,7 +483,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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) {
|
||||
|
||||
@@ -110,14 +110,10 @@ onMounted(() => { reloadData() })
|
||||
.page-title { @apply text-2xl font-bold text-slate-900; }
|
||||
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
||||
.mini-stat { @apply rounded-xl text-white p-5 shadow-sm; }
|
||||
.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 { @apply rounded-xl p-5 shadow-sm; background:#fffaf2; color:#171715; border:1px solid #d8cebf; box-shadow:0 10px 24px rgba(23,22,20,0.04); }
|
||||
.mini-stat__value { @apply text-3xl font-bold; }
|
||||
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
||||
.panel-card { @apply bg-white rounded-xl 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; }
|
||||
.table-footer { @apply flex justify-end mt-4; }
|
||||
</style>
|
||||
|
||||
@@ -239,19 +239,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
@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 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 {
|
||||
|
||||
@@ -329,7 +329,7 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.cart-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
<div>
|
||||
<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">
|
||||
<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-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 class="text-sm text-gray-600 mt-4">
|
||||
<p>开始时间:{{ formatTime(flashSale.startTime) }}</p>
|
||||
@@ -67,15 +67,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<div class="flex items-center text-blue-700">
|
||||
<div class="note-card mb-6 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<el-icon class="mr-2"><InfoFilled /></el-icon>
|
||||
<span>每人限购 {{ flashSale.limitPerUser }} 件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<el-button 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>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
@@ -85,7 +85,7 @@
|
||||
</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>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 秒杀商品数量有限,先到先得</li>
|
||||
@@ -158,9 +158,9 @@ const stockPercent = computed(() => {
|
||||
})
|
||||
|
||||
const progressColor = computed(() => {
|
||||
if (stockPercent.value > 50) return '#67c23a'
|
||||
if (stockPercent.value > 20) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
if (stockPercent.value > 50) return '#171715'
|
||||
if (stockPercent.value > 20) return '#5e5e58'
|
||||
return '#9f9f99'
|
||||
})
|
||||
|
||||
const endTime = computed(() => {
|
||||
@@ -243,6 +243,30 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.flashsale-detail-page {
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
<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="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-label">即将开始</div>
|
||||
<el-icon :size="30" class="stat-icon"><Clock /></el-icon>
|
||||
</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-label">正在进行</div>
|
||||
<el-icon :size="30" class="stat-icon"><Lightning /></el-icon>
|
||||
</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-label">我的参与</div>
|
||||
<el-icon :size="30" class="stat-icon"><Trophy /></el-icon>
|
||||
</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-label">抢购成功</div>
|
||||
<el-icon :size="30" class="stat-icon"><SuccessFilled /></el-icon>
|
||||
@@ -170,9 +170,6 @@ const loadFlashSales = async () => {
|
||||
if (res.success) {
|
||||
flashSales.value = res.data.content
|
||||
pagination.total = res.data.totalElements
|
||||
|
||||
// 更新统计信息
|
||||
updateStatistics()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载秒杀活动失败:', error)
|
||||
@@ -181,27 +178,18 @@ const loadFlashSales = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
const updateStatistics = () => {
|
||||
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 () => {
|
||||
// 加载统计信息(从后端获取真实数据)
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
const res = await flashsaleApi.getUserRecords()
|
||||
const res = await flashsaleApi.getStatistics()
|
||||
if (res.success) {
|
||||
statistics.participated = res.data.length
|
||||
statistics.success = res.data.filter((item: any) => item.success).length
|
||||
statistics.upcoming = res.data.upcoming ?? 0
|
||||
statistics.active = res.data.active ?? 0
|
||||
statistics.participated = res.data.participated ?? 0
|
||||
statistics.success = res.data.success ?? 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户统计失败:', error)
|
||||
console.error('加载统计信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,33 +229,44 @@ const handleParticipate = async (flashSaleId: number) => {
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
loadFlashSales()
|
||||
loadStatistics()
|
||||
ElMessage.success('已刷新')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFlashSales()
|
||||
loadStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flashsale-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-icon {
|
||||
color: #44443f;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm opacity-90 mt-1;
|
||||
@apply text-sm mt-1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
@apply absolute right-4 bottom-4 opacity-30;
|
||||
@apply absolute right-4 bottom-4;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
219
flash-sale-frontend/src/pages/groupbuying/detail.vue
Normal file
219
flash-sale-frontend/src/pages/groupbuying/detail.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="page-container py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ detail?.productName || '加载中...' }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<div v-if="loading" class="text-center py-20">
|
||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- 商品信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<SafeImage
|
||||
:src="detail.productImageUrl"
|
||||
:alt="detail.productName"
|
||||
wrapper-class="w-full h-96 rounded-2xl overflow-hidden"
|
||||
img-class="w-full h-96 object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<el-tag :type="statusType" effect="dark" class="mb-3">{{ detail.statusDescription }}</el-tag>
|
||||
<h1 class="text-2xl font-bold mb-4">{{ detail.productName }}</h1>
|
||||
|
||||
<div class="price-section mb-4">
|
||||
<div class="flex items-end gap-3">
|
||||
<span class="text-3xl font-bold" style="color: #171715">¥{{ detail.groupPrice }}</span>
|
||||
<span class="text-lg text-gray-400 line-through">¥{{ detail.productPrice }}</span>
|
||||
<el-tag type="danger" size="small">省 ¥{{ detail.discount }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section space-y-3 mb-6">
|
||||
<div class="flex items-center text-gray-600">
|
||||
<el-icon class="mr-2"><User /></el-icon>
|
||||
<span>{{ detail.requiredMembers }} 人成团</span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600">
|
||||
<el-icon class="mr-2"><Timer /></el-icon>
|
||||
<span>开团后 {{ detail.durationMinutes }} 分钟内有效</span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600">
|
||||
<el-icon class="mr-2"><Box /></el-icon>
|
||||
<span>剩余库存: {{ detail.remainingStock }} / {{ detail.totalStock }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600">
|
||||
<el-icon class="mr-2"><Warning /></el-icon>
|
||||
<span>每人限购 {{ detail.maxPerUser }} 件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-progress :percentage="stockPercent" :stroke-width="8" :show-text="false" :color="progressColor" class="mb-6" />
|
||||
|
||||
<div class="flex gap-3">
|
||||
<el-button type="primary" size="large" :disabled="!canJoin" @click="handleCreateGroup" :loading="joining">
|
||||
<el-icon class="mr-1"><Connection /></el-icon>
|
||||
一键开团
|
||||
</el-button>
|
||||
<el-button size="large" @click="$router.push(`/product/${detail.productId}`)">
|
||||
查看商品
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则说明 -->
|
||||
<div class="rules-section mb-8 p-6 rounded-xl" style="background: #fffaf2; border: 1px solid #e8e0d4">
|
||||
<h3 class="text-lg font-bold mb-3">拼团规则</h3>
|
||||
<ul class="space-y-2 text-gray-600 text-sm">
|
||||
<li>1. 用户可以发起新团或加入已有团组</li>
|
||||
<li>2. 开团后 {{ detail.durationMinutes }} 分钟内需凑满 {{ detail.requiredMembers }} 人</li>
|
||||
<li>3. 成团后按拼团价生成订单,未成团自动退款</li>
|
||||
<li>4. 每人限购 {{ detail.maxPerUser }} 件</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 进行中的团组 -->
|
||||
<div class="groups-section">
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
进行中的团组
|
||||
<span class="text-sm text-gray-400 ml-2">({{ groups.length }} 个)</span>
|
||||
</h2>
|
||||
|
||||
<div v-if="groups.length === 0" class="text-center py-10">
|
||||
<el-empty description="暂无进行中的团组,快来开团吧!" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="group in groups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
|
||||
style="background: #fffaf2; border: 1px solid #e8e0d4">
|
||||
<div class="flex items-center gap-4">
|
||||
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
||||
<div>
|
||||
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
还差 {{ group.requiredMembers - group.currentMembers }} 人 |
|
||||
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroups" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex -space-x-2">
|
||||
<el-avatar v-for="m in group.members.slice(0, 5)" :key="m.userId" :size="28" :src="m.avatar">
|
||||
{{ m.username ? m.username[0] : '?' }}
|
||||
</el-avatar>
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click="handleJoinGroup(group.id)" :loading="joining">
|
||||
参团
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import type { GroupBuying, GroupBuyingGroup } from '@/types/api'
|
||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import CountDown from '@/components/business/CountDown.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const joining = ref(false)
|
||||
const detail = ref<GroupBuying | null>(null)
|
||||
const groups = ref<GroupBuyingGroup[]>([])
|
||||
|
||||
const id = computed(() => Number(route.params.id))
|
||||
|
||||
const statusType = computed(() => {
|
||||
switch (detail.value?.status) {
|
||||
case 'UPCOMING': return 'warning'
|
||||
case 'ACTIVE': return 'success'
|
||||
case 'ENDED': return 'info'
|
||||
default: return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const stockPercent = computed(() => {
|
||||
if (!detail.value || detail.value.totalStock === 0) return 0
|
||||
return Math.round(detail.value.remainingStock / detail.value.totalStock * 100)
|
||||
})
|
||||
|
||||
const progressColor = computed(() => (stockPercent.value > 50 ? '#171715' : stockPercent.value > 20 ? '#5e5e58' : '#9f9f99'))
|
||||
|
||||
const canJoin = computed(() => detail.value?.status === 'ACTIVE' && (detail.value?.remainingStock ?? 0) > 0)
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await groupbuyingApi.getDetail(id.value)
|
||||
detail.value = res.data
|
||||
await loadGroups()
|
||||
} catch (e) {
|
||||
console.error('加载拼团详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const res = await groupbuyingApi.getGroups(id.value, { page: 0, size: 50 })
|
||||
groups.value = res.data.content.filter(g => g.status === 'FORMING')
|
||||
} catch (e) {
|
||||
console.error('加载团组列表失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
joining.value = true
|
||||
try {
|
||||
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value })
|
||||
ElMessage.success(res.data.message || '开团成功')
|
||||
router.push(`/groupbuying/group/${res.data.groupId}`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '开团失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinGroup = async (groupId: number) => {
|
||||
joining.value = true
|
||||
try {
|
||||
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value, groupId })
|
||||
ElMessage.success(res.data.message || '加入成功')
|
||||
router.push(`/groupbuying/group/${res.data.groupId}`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.price-section {
|
||||
@apply p-4 rounded-xl;
|
||||
background: #fffaf2;
|
||||
border: 1px solid #e8e0d4;
|
||||
}
|
||||
</style>
|
||||
167
flash-sale-frontend/src/pages/groupbuying/group.vue
Normal file
167
flash-sale-frontend/src/pages/groupbuying/group.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="page-container py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>团组详情</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<div v-if="loading" class="text-center py-20">
|
||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||
</div>
|
||||
|
||||
<template v-else-if="group">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- 团组状态 -->
|
||||
<div class="status-section text-center mb-8 p-8 rounded-2xl" style="background: #fffaf2; border: 1px solid #e8e0d4">
|
||||
<el-tag :type="statusType" effect="dark" size="large" class="mb-4">{{ group.statusDescription }}</el-tag>
|
||||
|
||||
<h2 class="text-xl font-bold mb-2">{{ group.groupBuying?.productName }}</h2>
|
||||
<div class="text-2xl font-bold mb-4" style="color: #171715">¥{{ group.groupBuying?.groupPrice }}</div>
|
||||
|
||||
<div v-if="group.status === 'FORMING'" class="mb-4">
|
||||
<p class="text-gray-500 mb-2">还差 <span class="font-bold text-lg" style="color: #171715">{{ group.requiredMembers - group.currentMembers }}</span> 人成团</p>
|
||||
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroup" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="group.status === 'SUCCESS'" class="mb-4">
|
||||
<el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||
<p class="text-green-600 mt-2 font-semibold">拼团成功!</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-4">
|
||||
<el-icon :size="48" color="#909399"><CircleCloseFilled /></el-icon>
|
||||
<p class="text-gray-500 mt-2">拼团未成功</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div class="members-section mb-8">
|
||||
<h3 class="text-lg font-bold mb-4">团成员 ({{ group.currentMembers }}/{{ group.requiredMembers }})</h3>
|
||||
<GroupMemberList
|
||||
:members="group.members"
|
||||
:required-members="group.requiredMembers"
|
||||
:leader-user-id="group.leaderUserId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3 justify-center">
|
||||
<el-button v-if="group.status === 'FORMING' && !isInGroup" type="primary" size="large" @click="handleJoin" :loading="joining">
|
||||
<el-icon class="mr-1"><Connection /></el-icon>
|
||||
加入拼团
|
||||
</el-button>
|
||||
<el-button v-if="group.status === 'FORMING' && isInGroup" type="danger" size="large" @click="handleCancel" :loading="cancelling">
|
||||
退出团组
|
||||
</el-button>
|
||||
<el-button size="large" @click="$router.push(`/groupbuying/${group.groupBuyingId}`)">
|
||||
查看活动
|
||||
</el-button>
|
||||
<el-button v-if="group.status === 'FORMING'" size="large" @click="handleShare">
|
||||
<el-icon class="mr-1"><Share /></el-icon>
|
||||
邀请好友
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import type { GroupBuyingGroup } from '@/types/api'
|
||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GroupMemberList from '@/components/business/GroupMemberList.vue'
|
||||
import CountDown from '@/components/business/CountDown.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(true)
|
||||
const joining = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const group = ref<GroupBuyingGroup | null>(null)
|
||||
|
||||
const groupId = computed(() => Number(route.params.id))
|
||||
|
||||
const statusType = computed(() => {
|
||||
switch (group.value?.status) {
|
||||
case 'FORMING': return 'warning'
|
||||
case 'SUCCESS': return 'success'
|
||||
case 'FAILED': return 'info'
|
||||
default: return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const isInGroup = computed(() => {
|
||||
if (!group.value || !userStore.user) return false
|
||||
return group.value.members.some(m => m.userId === userStore.user?.id)
|
||||
})
|
||||
|
||||
const loadGroup = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await groupbuyingApi.getGroupDetail(groupId.value)
|
||||
group.value = res.data
|
||||
} catch (e) {
|
||||
console.error('加载团组详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!group.value) return
|
||||
joining.value = true
|
||||
try {
|
||||
const res = await groupbuyingApi.joinGroup({
|
||||
groupBuyingId: group.value.groupBuyingId,
|
||||
groupId: group.value.id,
|
||||
})
|
||||
ElMessage.success(res.data.message || '加入成功')
|
||||
await loadGroup()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出该团组吗?退出后订单将自动取消。', '提示', {
|
||||
confirmButtonText: '确定退出',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
cancelling.value = true
|
||||
await groupbuyingApi.cancelMembership(groupId.value)
|
||||
ElMessage.success('已退出团组')
|
||||
await loadGroup()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.message || '退出失败')
|
||||
}
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleShare = () => {
|
||||
const url = window.location.href
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
ElMessage.success('链接已复制,快分享给好友吧!')
|
||||
}).catch(() => {
|
||||
ElMessage.info('请手动复制链接分享')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadGroup)
|
||||
</script>
|
||||
148
flash-sale-frontend/src/pages/groupbuying/index.vue
Normal file
148
flash-sale-frontend/src/pages/groupbuying/index.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="page-container py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- 页头 -->
|
||||
<div class="flex items-center mb-6">
|
||||
<el-icon :size="28" class="mr-2"><Connection /></el-icon>
|
||||
<h1 class="text-2xl font-bold">拼团活动</h1>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar mb-6 flex flex-wrap items-center gap-4">
|
||||
<el-radio-group v-model="filters.status" @change="loadList">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="ACTIVE">进行中</el-radio-button>
|
||||
<el-radio-button label="UPCOMING">即将开始</el-radio-button>
|
||||
<el-radio-button label="ENDED">已结束</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.activeActivities }}</div>
|
||||
<div class="stat-label">进行中</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.myGroups }}</div>
|
||||
<div class="stat-label">我参与的</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.successGroups }}</div>
|
||||
<div class="stat-label">已成团</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">¥{{ stats.totalSaved }}</div>
|
||||
<div class="stat-label">已节省</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-20">
|
||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="list.length === 0" class="text-center py-20">
|
||||
<el-empty description="暂无拼团活动" />
|
||||
</div>
|
||||
|
||||
<!-- 活动网格 -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<GroupBuyingCard
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:data="item"
|
||||
@join="handleJoin"
|
||||
@refresh="loadList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalElements > 0" class="flex justify-center mt-8">
|
||||
<el-pagination
|
||||
:current-page="filters.page + 1"
|
||||
:page-size="filters.size"
|
||||
:total="totalElements"
|
||||
layout="prev, pager, next"
|
||||
@current-change="(p: number) => { filters.page = p - 1; loadList() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import type { GroupBuying, GroupBuyingStatistics } from '@/types/api'
|
||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
||||
import GroupBuyingCard from '@/components/business/GroupBuyingCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const list = ref<GroupBuying[]>([])
|
||||
const totalElements = ref(0)
|
||||
const stats = ref<GroupBuyingStatistics>({
|
||||
totalActivities: 0,
|
||||
activeActivities: 0,
|
||||
myGroups: 0,
|
||||
successGroups: 0,
|
||||
totalSaved: 0,
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
status: '' as string,
|
||||
page: 0,
|
||||
size: 12,
|
||||
})
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [listRes, statsRes] = await Promise.all([
|
||||
groupbuyingApi.getList({
|
||||
page: filters.page,
|
||||
size: filters.size,
|
||||
status: filters.status || undefined,
|
||||
}),
|
||||
groupbuyingApi.getStatistics(),
|
||||
])
|
||||
|
||||
list.value = listRes.data.content
|
||||
totalElements.value = listRes.data.totalElements
|
||||
stats.value = statsRes.data
|
||||
} catch (e) {
|
||||
console.error('加载拼团列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoin = (id: number) => {
|
||||
router.push(`/groupbuying/${id}`)
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stat-card {
|
||||
@apply bg-white rounded-xl p-4 text-center;
|
||||
background: #fffaf2;
|
||||
border: 1px solid #e8e0d4;
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold;
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm text-gray-500 mt-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,11 +7,11 @@
|
||||
<div class="container mx-auto px-4 h-full">
|
||||
<div class="flex items-center h-full">
|
||||
<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>
|
||||
{{ item.title }}
|
||||
</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">
|
||||
<el-button size="large" type="primary" @click="router.push(item.link)">
|
||||
{{ item.buttonText }}
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
</el-icon>
|
||||
</div>
|
||||
@@ -33,11 +33,43 @@
|
||||
</el-carousel>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 商品分类 -->
|
||||
<section class="mb-12">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center">
|
||||
<el-icon class="section-icon mr-2"><Grid /></el-icon>
|
||||
商品分类
|
||||
</h2>
|
||||
<el-button text @click="router.push('/products')">
|
||||
全部商品
|
||||
<el-icon class="ml-1"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingCategories" class="text-center py-8">
|
||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div
|
||||
v-for="cat in categoryList"
|
||||
:key="cat.name"
|
||||
class="category-card cursor-pointer"
|
||||
@click="router.push(`/products?category=${encodeURIComponent(cat.name)}`)"
|
||||
>
|
||||
<el-icon :size="32" class="category-icon mb-2">
|
||||
<component :is="cat.icon" />
|
||||
</el-icon>
|
||||
<span class="text-sm font-medium">{{ cat.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 正在秒杀 -->
|
||||
<section class="mb-12">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center">
|
||||
<el-icon class="text-red-500 mr-2"><Lightning /></el-icon>
|
||||
<el-icon class="section-icon mr-2"><Lightning /></el-icon>
|
||||
正在秒杀
|
||||
</h2>
|
||||
<el-button text @click="router.push('/flashsale')">
|
||||
@@ -69,7 +101,7 @@
|
||||
<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="text-orange-500 mr-2"><Star /></el-icon>
|
||||
<el-icon class="section-icon mr-2"><Star /></el-icon>
|
||||
热门商品
|
||||
</h2>
|
||||
<el-button text @click="router.push('/products')">
|
||||
@@ -102,22 +134,22 @@
|
||||
<h2 class="text-2xl font-bold text-center mb-8">系统特性</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="feature-card">
|
||||
<el-icon :size="40" class="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>
|
||||
<p class="text-gray-600">高并发秒杀系统,支持大量用户同时抢购</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-gray-600">分布式锁机制,确保库存数据一致性</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-gray-600">五种数据类型应用,毫秒级响应</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-gray-600">多种限流策略,防止恶意刷单</p>
|
||||
</div>
|
||||
@@ -151,7 +183,7 @@ const banners = [
|
||||
subtitle: '基于Redis集群构建的高并发秒杀系统',
|
||||
buttonText: '立即抢购',
|
||||
link: '/flashsales',
|
||||
bgColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
bgColor: '#ffffff',
|
||||
icon: 'Lightning'
|
||||
},
|
||||
{
|
||||
@@ -160,7 +192,7 @@ const banners = [
|
||||
subtitle: '采用分布式锁和Lua脚本,确保数据一致性',
|
||||
buttonText: '了解更多',
|
||||
link: '/flashsales',
|
||||
bgColor: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
bgColor: '#ffffff',
|
||||
icon: 'Lock'
|
||||
},
|
||||
{
|
||||
@@ -169,17 +201,51 @@ const banners = [
|
||||
subtitle: 'Redis集群架构,毫秒级响应',
|
||||
buttonText: '查看商品',
|
||||
link: '/products',
|
||||
bgColor: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
bgColor: '#ffffff',
|
||||
icon: 'Odometer'
|
||||
}
|
||||
]
|
||||
|
||||
// 分类图标映射
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
'电子产品': 'Monitor',
|
||||
'家电': 'House',
|
||||
'服饰鞋包': 'Goods',
|
||||
'图书音像': 'Reading',
|
||||
'食品饮料': 'Coffee',
|
||||
'运动户外': 'Trophy',
|
||||
'美妆护肤': 'MagicStick',
|
||||
'家居日用': 'Box',
|
||||
'母婴玩具': 'Present',
|
||||
'数码配件': 'Cellphone',
|
||||
}
|
||||
|
||||
// 数据状态
|
||||
const loadingCategories = ref(false)
|
||||
const loadingFlashSales = ref(false)
|
||||
const loadingProducts = ref(false)
|
||||
const categoryList = ref<{ name: string; icon: string }[]>([])
|
||||
const activeFlashSales = ref<FlashSale[]>([])
|
||||
const hotProducts = ref<Product[]>([])
|
||||
|
||||
// 加载分类
|
||||
const loadCategories = async () => {
|
||||
loadingCategories.value = true
|
||||
try {
|
||||
const res = await productApi.getCategories()
|
||||
if (res.success) {
|
||||
categoryList.value = res.data.map((name: string) => ({
|
||||
name,
|
||||
icon: categoryIconMap[name] || 'Goods',
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
} finally {
|
||||
loadingCategories.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载秒杀活动
|
||||
const loadFlashSales = async () => {
|
||||
loadingFlashSales.value = true
|
||||
@@ -234,6 +300,7 @@ const handleAddToCart = async (productId: number) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadFlashSales()
|
||||
loadProducts()
|
||||
})
|
||||
@@ -248,10 +315,48 @@ onMounted(() => {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: #fffaf2;
|
||||
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
|
||||
}
|
||||
|
||||
.banner-title,
|
||||
.banner-subtitle {
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.banner-illustration {
|
||||
color: #171715;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.section-icon,
|
||||
.feature-icon {
|
||||
color: #44443f;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
@apply flex flex-col items-center justify-center p-5 rounded-2xl transition-all;
|
||||
border: 1px solid #d8cebf;
|
||||
background: #fffaf2;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
color: #44443f;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
@apply bg-white p-6 rounded-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) {
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
|
||||
</template>
|
||||
<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 text type="danger" @click="handleDelete">删除订单</el-button>
|
||||
</template>
|
||||
@@ -93,6 +94,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewDialog
|
||||
v-if="order"
|
||||
v-model:visible="reviewDialogVisible"
|
||||
:order-id="order.id"
|
||||
:order-items="order.items"
|
||||
@success="checkAllReviewed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -107,6 +116,7 @@ import { useCartStore } from '@/stores/cart'
|
||||
import type { Order } from '@/types/api'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -114,6 +124,8 @@ const cartStore = useCartStore()
|
||||
|
||||
const loading = ref(false)
|
||||
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 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) }
|
||||
}
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!order.value) return
|
||||
const firstItem = order.value.items[0]
|
||||
if (!firstItem) return
|
||||
const checkAllReviewed = async () => {
|
||||
if (!order.value || order.value.status !== 'COMPLETED') return
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', { inputType: 'textarea', inputPlaceholder: '分享一下你的使用感受吧', confirmButtonText: '提交评价', cancelButtonText: '取消' })
|
||||
await reviewApi.create({ orderId: order.value.id, productId: firstItem.productId, rating: 5, content: value })
|
||||
ElMessage.success('评价提交成功')
|
||||
} catch (error) {
|
||||
if (error) console.error('提交评价失败:', error)
|
||||
const checks = await Promise.all(
|
||||
order.value.items.map(item => reviewApi.checkReview(order.value!.id, item.productId).catch(() => null))
|
||||
)
|
||||
allReviewed.value = checks.every(res => res?.success && res.data.reviewed)
|
||||
} catch {
|
||||
allReviewed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +206,15 @@ const handleDelete = async () => {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-detail-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
</template>
|
||||
|
||||
<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 text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
||||
</template>
|
||||
@@ -101,6 +102,14 @@
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<ReviewDialog
|
||||
v-if="currentReviewOrder"
|
||||
v-model:visible="reviewDialogVisible"
|
||||
:order-id="currentReviewOrder.id"
|
||||
:order-items="currentReviewOrder.items"
|
||||
@success="onReviewSuccess"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -115,6 +124,7 @@ import { useCartStore } from '@/stores/cart'
|
||||
import type { Order } from '@/types/api'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
@@ -124,6 +134,9 @@ const orders = ref<Order[]>([])
|
||||
|
||||
const filters = reactive({ status: '', keyword: '' })
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
const reviewDialogVisible = ref(false)
|
||||
const currentReviewOrder = ref<Order | null>(null)
|
||||
const orderReviewStatus = ref<Record<number, boolean>>({})
|
||||
|
||||
const orderStats = ref([
|
||||
{ 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
|
||||
pagination.total = res.data.totalElements
|
||||
checkOrdersReviewStatus(orders.value)
|
||||
}
|
||||
} finally {
|
||||
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) }
|
||||
}
|
||||
|
||||
const handleReview = async (order: Order) => {
|
||||
const firstItem = order.items[0]
|
||||
if (!firstItem) return
|
||||
|
||||
const checkOrdersReviewStatus = async (orderList: Order[]) => {
|
||||
const completed = orderList.filter(o => o.status === 'COMPLETED')
|
||||
await Promise.all(
|
||||
completed.map(async (order) => {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', {
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '分享一下你的使用感受吧',
|
||||
confirmButtonText: '提交评价',
|
||||
cancelButtonText: '取消',
|
||||
const checks = await Promise.all(
|
||||
order.items.map(item => reviewApi.checkReview(order.id, item.productId).catch(() => null))
|
||||
)
|
||||
orderReviewStatus.value[order.id] = checks.every(res => res?.success && res.data.reviewed)
|
||||
} catch {
|
||||
orderReviewStatus.value[order.id] = false
|
||||
}
|
||||
})
|
||||
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">
|
||||
.orders-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,15 +163,29 @@
|
||||
|
||||
<el-tab-pane label="用户评价" name="reviews">
|
||||
<div class="py-6">
|
||||
<div class="mb-4 flex items-center justify-between bg-gray-50 rounded-lg p-4">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
||||
<div class="text-sm text-gray-500">累计 {{ reviewSummary.totalReviews }} 条评价</div>
|
||||
<div class="mb-6 bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex items-center gap-8 mb-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
||||
<el-rate :model-value="reviewSummary.averageRating" 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>
|
||||
<el-rate :model-value="reviewSummary.averageRating" disabled show-score text-color="#f59e0b" />
|
||||
</div>
|
||||
<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="font-semibold">{{ review.username }}</div>
|
||||
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
|
||||
@@ -183,6 +197,14 @@
|
||||
<div>{{ review.adminReply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="reviewSummary.reviews.length > reviewPageSize" class="flex justify-center mt-6">
|
||||
<el-pagination
|
||||
v-model:current-page="reviewPage"
|
||||
:page-size="reviewPageSize"
|
||||
:total="reviewSummary.reviews.length"
|
||||
layout="prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无评价" />
|
||||
</div>
|
||||
@@ -212,11 +234,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import type { ReviewItem } from '@/api/modules/review'
|
||||
import { favoriteApi } from '@/api/modules/favorite'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -236,15 +259,37 @@ const currentImage = ref('')
|
||||
const quantity = ref(1)
|
||||
const activeTab = ref('detail')
|
||||
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 displayedReviews = computed(() => {
|
||||
const start = (reviewPage.value - 1) * reviewPageSize
|
||||
return reviewSummary.value.reviews.slice(start, start + reviewPageSize)
|
||||
})
|
||||
|
||||
const getRatingCount = (star: number) => {
|
||||
return reviewSummary.value.reviews.filter(r => r.rating === star).length
|
||||
}
|
||||
|
||||
const getRatingPercentage = (star: number) => {
|
||||
const total = reviewSummary.value.reviews.length
|
||||
if (total === 0) return 0
|
||||
return Math.round((getRatingCount(star) / total) * 100)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 处理图片错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
if (target) {
|
||||
target.src = defaultProductImage
|
||||
}
|
||||
}
|
||||
|
||||
// 加载商品详情
|
||||
const loadProductDetail = async () => {
|
||||
@@ -345,13 +390,16 @@ const handleFavorite = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadProductDetail()
|
||||
if (route.query.tab === 'reviews') {
|
||||
activeTab.value = 'reviews'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-detail-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prose {
|
||||
|
||||
@@ -4,12 +4,34 @@
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
<p class="text-gray-600">精选好物,品质保证</p>
|
||||
</div>
|
||||
|
||||
<!-- 分类标签栏 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<el-tag
|
||||
:effect="!filters.category ? 'dark' : 'plain'"
|
||||
class="cursor-pointer category-tag"
|
||||
@click="selectCategory('')"
|
||||
>
|
||||
全部
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:effect="filters.category === cat ? 'dark' : 'plain'"
|
||||
class="cursor-pointer category-tag"
|
||||
@click="selectCategory(cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
@@ -111,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ProductCard from '@/components/business/ProductCard.vue'
|
||||
@@ -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) => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
@@ -213,15 +244,46 @@ onMounted(() => {
|
||||
if (route.query.keyword) {
|
||||
filters.keyword = route.query.keyword as string
|
||||
}
|
||||
// 从路由参数获取分类
|
||||
if (route.query.category) {
|
||||
filters.category = route.query.category as string
|
||||
}
|
||||
|
||||
loadCategories()
|
||||
loadProducts()
|
||||
})
|
||||
|
||||
// 监听路由参数变化(同一页面内跳转时触发)
|
||||
watch(() => route.query, (newQuery) => {
|
||||
const newCategory = (newQuery.category as string) || ''
|
||||
const newKeyword = (newQuery.keyword as string) || ''
|
||||
if (newCategory !== filters.category || newKeyword !== filters.keyword) {
|
||||
filters.category = newCategory
|
||||
filters.keyword = newKeyword
|
||||
pagination.page = 1
|
||||
loadProducts()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.products-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-icon {
|
||||
color: #44443f;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
font-size: 14px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -94,7 +94,7 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.favorites-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="login-page min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="login-panel bg-white p-8">
|
||||
<!-- Logo -->
|
||||
<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 />
|
||||
</el-icon>
|
||||
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
||||
@@ -59,20 +59,6 @@
|
||||
</el-button>
|
||||
</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">
|
||||
<span class="text-gray-600">还没有账号?</span>
|
||||
<router-link to="/register" class="text-primary-500 hover:underline">
|
||||
@@ -81,15 +67,6 @@
|
||||
</div>
|
||||
</el-form>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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>
|
||||
|
||||
184
flash-sale-frontend/src/pages/user/notifications.vue
Normal file
184
flash-sale-frontend/src/pages/user/notifications.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto py-6 px-4">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>消息通知</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">消息通知</h2>
|
||||
<div class="flex gap-2">
|
||||
<el-button size="small" @click="handleMarkAllRead" :disabled="unreadCount === 0">
|
||||
全部已读
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleClearAll" :disabled="notifications.length === 0">
|
||||
清空全部
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签筛选 -->
|
||||
<el-tabs v-model="activeType" @tab-change="loadNotifications">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="秒杀" name="flashsale" />
|
||||
<el-tab-pane label="订单" name="order" />
|
||||
<el-tab-pane label="系统" name="system" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="32" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<div v-else-if="notifications.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50"
|
||||
:class="{ 'bg-orange-50/50 border-orange-200': !item.read }"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<el-icon :size="20" class="mt-0.5" :class="getIconColor(item.type)">
|
||||
<component :is="getIcon(item.type)" />
|
||||
</el-icon>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium" :class="{ 'font-semibold': !item.read }">{{ item.title }}</span>
|
||||
<el-tag v-if="!item.read" type="danger" size="small" effect="light">未读</el-tag>
|
||||
<el-tag size="small" effect="plain">{{ getTypeLabel(item.type) }}</el-tag>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-2">{{ item.message }}</p>
|
||||
<span class="text-xs text-gray-400">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!item.read"
|
||||
text
|
||||
size="small"
|
||||
@click.stop="handleMarkRead(item)"
|
||||
>
|
||||
标记已读
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-else description="暂无消息通知" class="py-12" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { notificationApi } from '@/api/modules/notification'
|
||||
import type { NotificationItem } from '@/api/modules/notification'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const activeType = ref('all')
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
|
||||
|
||||
const loadNotifications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const type = activeType.value === 'all' ? undefined : activeType.value
|
||||
const res = await notificationApi.getList(type)
|
||||
if (res?.success) {
|
||||
notifications.value = res.data || []
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取通知失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (item: NotificationItem) => {
|
||||
try {
|
||||
await notificationApi.markAsRead(item.id)
|
||||
item.read = true
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await notificationApi.markAllAsRead()
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
ElMessage.success('已全部标记为已读')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要清空所有通知吗?', '提示', { type: 'warning' })
|
||||
await notificationApi.clearAll()
|
||||
notifications.value = []
|
||||
ElMessage.success('已清空所有通知')
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = async (item: NotificationItem) => {
|
||||
if (!item.read) {
|
||||
await notificationApi.markAsRead(item.id).catch(() => {})
|
||||
item.read = true
|
||||
}
|
||||
if (item.link) {
|
||||
router.push(item.link)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return dayjs(dateStr).fromNow()
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
flashsale: 'Lightning',
|
||||
order: 'List',
|
||||
system: 'InfoFilled'
|
||||
}
|
||||
return icons[type] || 'InfoFilled'
|
||||
}
|
||||
|
||||
const getIconColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
flashsale: 'text-orange-500',
|
||||
order: 'text-blue-500',
|
||||
system: 'text-gray-500'
|
||||
}
|
||||
return colors[type] || 'text-gray-500'
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
flashsale: '秒杀',
|
||||
order: '订单',
|
||||
system: '系统'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications()
|
||||
})
|
||||
</script>
|
||||
@@ -326,16 +326,15 @@ onMounted(async () => {
|
||||
<style scoped lang="scss">
|
||||
.profile-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply rounded-lg p-5 text-white shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
@apply rounded-lg p-5 shadow-sm;
|
||||
background: #fffaf2;
|
||||
color: #171715;
|
||||
border: 1px solid #d8cebf;
|
||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="register-page min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="register-panel bg-white p-8">
|
||||
<!-- Logo -->
|
||||
<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 />
|
||||
</el-icon>
|
||||
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
|
||||
@@ -192,6 +192,17 @@ const handleRegister = async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.register-page {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-mark {
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.register-panel {
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 24px;
|
||||
background: #fffaf2;
|
||||
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
|
||||
}
|
||||
</style>
|
||||
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="user-reviews-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>我的评价</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">我的评价</h1>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="reviews.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||
<el-empty description="暂无评价,去购物吧">
|
||||
<el-button type="primary" @click="router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="review in paginatedReviews" :key="review.id" class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex gap-4">
|
||||
<SafeImage
|
||||
:src="review.productImage"
|
||||
:alt="review.productName"
|
||||
wrapper-class="w-20 h-20 rounded cursor-pointer"
|
||||
img-class="w-20 h-20 object-cover rounded"
|
||||
@click="router.push(`/product/${review.productId}`)"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold cursor-pointer hover:text-blue-500 inline"
|
||||
@click="router.push(`/product/${review.productId}`)"
|
||||
>
|
||||
{{ review.productName || '商品' }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="review.orderId"
|
||||
class="ml-3 text-xs text-gray-400 cursor-pointer hover:text-blue-400"
|
||||
@click="router.push(`/order/${review.orderId}`)"
|
||||
>
|
||||
订单 #{{ review.orderId }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</span>
|
||||
</div>
|
||||
<el-rate :model-value="review.rating" disabled />
|
||||
<p class="text-gray-600 mt-2 leading-6">{{ review.content }}</p>
|
||||
|
||||
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm">
|
||||
<div class="font-medium text-gray-800 mb-1">商家回复</div>
|
||||
<div class="text-gray-600">{{ review.adminReply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reviews.length > pageSize" class="mt-8 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="reviews.length"
|
||||
layout="prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import type { ReviewItem } from '@/api/modules/review'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const reviews = ref<ReviewItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
|
||||
const paginatedReviews = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return reviews.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
const loadReviews = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await reviewApi.getMyReviews()
|
||||
if (res.success) {
|
||||
reviews.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评价失败:', error)
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-reviews-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -80,6 +80,36 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/user/favorites.vue'),
|
||||
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',
|
||||
name: 'Addresses',
|
||||
@@ -123,6 +153,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/admin/flashsales.vue'),
|
||||
meta: { title: '秒杀管理' }
|
||||
},
|
||||
{
|
||||
path: 'groupbuying',
|
||||
name: 'AdminGroupBuying',
|
||||
component: () => import('@/pages/admin/groupbuying.vue'),
|
||||
meta: { title: '拼团管理' }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'AdminOrders',
|
||||
|
||||
@@ -35,15 +35,31 @@ export const useUserStore = defineStore('user', () => {
|
||||
token.value = res.data.token
|
||||
user.value = res.data.user
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('token', token.value)
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
try {
|
||||
const profile = await userApi.getInfo()
|
||||
if (profile.success) {
|
||||
user.value = {
|
||||
...profile.data,
|
||||
avatar: profile.data.avatar || user.value?.avatar || '',
|
||||
}
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.error('登录成功但会话校验失败:', sessionError)
|
||||
user.value = null
|
||||
token.value = ''
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
ElMessage.error('登录成功但会话未建立,请检查 Cookie / 代理配置')
|
||||
return false
|
||||
}
|
||||
|
||||
// 跳转到之前的页面或首页
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
router.push(redirect || '/')
|
||||
await router.push(redirect || '/')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,49 +2,120 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
// 自定义变量
|
||||
:root {
|
||||
--primary-color: #ef4444;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--info-color: #3b82f6;
|
||||
--tone-0: #fffdf8;
|
||||
--tone-50: #f7f2ea;
|
||||
--tone-100: #efe7dc;
|
||||
--tone-200: #d8cebf;
|
||||
--tone-300: #c4b7a4;
|
||||
--tone-400: #9a8b76;
|
||||
--tone-500: #746855;
|
||||
--tone-600: #5c5346;
|
||||
--tone-700: #433d34;
|
||||
--tone-800: #2d2a25;
|
||||
--tone-900: #171614;
|
||||
--surface-muted: #f4ede4;
|
||||
--surface-raised: #fffaf2;
|
||||
--line-soft: #d8cebf;
|
||||
--line-strong: #171614;
|
||||
--shadow-soft: 0 14px 34px rgba(23, 22, 20, 0.06);
|
||||
--shadow-strong: 0 18px 40px rgba(23, 22, 20, 0.1);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 16px;
|
||||
|
||||
--primary-color: var(--tone-900);
|
||||
--success-color: var(--tone-700);
|
||||
--warning-color: var(--tone-600);
|
||||
--danger-color: var(--tone-900);
|
||||
--info-color: var(--tone-500);
|
||||
|
||||
--el-color-primary: var(--tone-900);
|
||||
--el-color-primary-light-3: var(--tone-700);
|
||||
--el-color-primary-light-5: var(--tone-600);
|
||||
--el-color-primary-light-7: var(--tone-400);
|
||||
--el-color-primary-light-8: var(--tone-300);
|
||||
--el-color-primary-light-9: var(--tone-100);
|
||||
--el-color-primary-dark-2: #0f0f0d;
|
||||
--el-color-success: var(--tone-700);
|
||||
--el-color-success-light-9: var(--tone-100);
|
||||
--el-color-warning: var(--tone-600);
|
||||
--el-color-warning-light-9: var(--tone-100);
|
||||
--el-color-danger: var(--tone-900);
|
||||
--el-color-danger-light-9: var(--tone-100);
|
||||
--el-color-info: var(--tone-500);
|
||||
--el-color-info-light-9: var(--tone-100);
|
||||
--el-border-color: var(--line-soft);
|
||||
--el-border-color-light: var(--line-soft);
|
||||
--el-border-color-lighter: var(--tone-100);
|
||||
--el-fill-color-light: var(--surface-muted);
|
||||
--el-fill-color-blank: var(--tone-0);
|
||||
--el-bg-color: var(--tone-0);
|
||||
--el-bg-color-page: var(--tone-50);
|
||||
--el-text-color-primary: var(--tone-900);
|
||||
--el-text-color-regular: var(--tone-700);
|
||||
--el-text-color-secondary: var(--tone-500);
|
||||
--el-text-color-placeholder: var(--tone-400);
|
||||
--el-mask-color: rgba(23, 23, 21, 0.52);
|
||||
--el-box-shadow-light: var(--shadow-soft);
|
||||
}
|
||||
|
||||
// 全局样式重置
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
html {
|
||||
background: var(--tone-50);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--tone-900);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 253, 248, 0.88), rgba(255, 253, 248, 0) 26%),
|
||||
linear-gradient(180deg, var(--tone-50) 0%, #f2ebdf 100%);
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--tone-50);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
background: #b8ab90;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: #8c7e6b;
|
||||
}
|
||||
|
||||
// 动画类
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
@@ -61,34 +132,383 @@ body {
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
|
||||
// 工具类
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: var(--tone-900);
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
background-color: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
background-color: var(--surface-raised) !important;
|
||||
border-color: var(--line-strong) !important;
|
||||
color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
border-radius: 8px;
|
||||
.el-button:hover,
|
||||
.el-button:focus,
|
||||
.el-button--primary:hover,
|
||||
.el-button--primary:focus,
|
||||
.el-button--danger:hover,
|
||||
.el-button--danger:focus {
|
||||
background-color: var(--tone-900) !important;
|
||||
border-color: var(--tone-900) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-button.is-text,
|
||||
.el-button--text {
|
||||
color: var(--tone-900) !important;
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
padding-left: 4px !important;
|
||||
padding-right: 4px !important;
|
||||
}
|
||||
|
||||
.el-button.is-text:hover,
|
||||
.el-button--text:hover {
|
||||
background: transparent !important;
|
||||
color: var(--tone-700) !important;
|
||||
}
|
||||
|
||||
.el-button--default:hover,
|
||||
.el-button--default:focus {
|
||||
background: var(--tone-50) !important;
|
||||
color: var(--tone-900) !important;
|
||||
border-color: var(--line-strong) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-date-editor.el-input__wrapper,
|
||||
.el-date-editor .el-input__wrapper {
|
||||
background: var(--surface-raised) !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: inset 0 0 0 1px var(--line-soft) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused,
|
||||
.el-textarea__inner:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong) !important;
|
||||
}
|
||||
|
||||
.el-radio-button__inner {
|
||||
border-radius: 14px !important;
|
||||
border-color: var(--line-soft) !important;
|
||||
color: var(--tone-900) !important;
|
||||
background: var(--surface-raised) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
background: var(--tone-0) !important;
|
||||
border-color: var(--line-strong) !important;
|
||||
color: var(--tone-900) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-card,
|
||||
.el-dialog,
|
||||
.el-popover,
|
||||
.el-message-box,
|
||||
.el-notification {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-soft) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-soft) !important;
|
||||
background: var(--surface-raised) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header,
|
||||
.el-message-box__header {
|
||||
margin: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
color: var(--tone-700);
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--line-soft);
|
||||
--el-table-header-bg-color: var(--surface-muted);
|
||||
--el-table-row-hover-bg-color: var(--tone-50);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: var(--surface-muted);
|
||||
color: var(--tone-900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-table tr {
|
||||
color: var(--tone-800);
|
||||
}
|
||||
|
||||
.el-tag,
|
||||
.el-tag--success,
|
||||
.el-tag--warning,
|
||||
.el-tag--danger,
|
||||
.el-tag--info,
|
||||
.el-tag--primary {
|
||||
background: var(--surface-raised) !important;
|
||||
border-color: var(--line-soft) !important;
|
||||
color: var(--tone-700) !important;
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
|
||||
.el-alert,
|
||||
.el-alert--success,
|
||||
.el-alert--warning,
|
||||
.el-alert--error,
|
||||
.el-alert--info {
|
||||
background: var(--surface-raised) !important;
|
||||
border: 1px solid var(--line-soft) !important;
|
||||
color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
color: var(--tone-500) !important;
|
||||
}
|
||||
|
||||
.el-tabs__item.is-active,
|
||||
.el-tabs__item:hover {
|
||||
color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-tabs__active-bar {
|
||||
background: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-progress-bar__outer {
|
||||
background: var(--surface-muted) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--line-soft) !important;
|
||||
}
|
||||
|
||||
.el-progress-bar__inner {
|
||||
background: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
--el-menu-bg-color: transparent;
|
||||
--el-menu-hover-bg-color: var(--surface-raised);
|
||||
--el-menu-active-color: var(--tone-900);
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:hover {
|
||||
background: var(--surface-muted) !important;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background: var(--surface-raised) !important;
|
||||
color: var(--tone-900) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
--el-pagination-button-bg-color: transparent;
|
||||
--el-pagination-hover-color: var(--tone-900);
|
||||
}
|
||||
|
||||
.el-pagination .btn-prev,
|
||||
.el-pagination .btn-next,
|
||||
.el-pagination .el-pager li {
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.el-pagination .el-pager li.is-active {
|
||||
background: var(--surface-raised) !important;
|
||||
color: var(--tone-900) !important;
|
||||
font-weight: 700;
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.el-badge__content {
|
||||
background: var(--surface-raised) !important;
|
||||
border-color: var(--line-strong) !important;
|
||||
color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-breadcrumb__inner.is-link,
|
||||
.el-breadcrumb__inner a {
|
||||
color: var(--tone-500) !important;
|
||||
}
|
||||
|
||||
.el-breadcrumb__inner {
|
||||
color: var(--tone-700) !important;
|
||||
}
|
||||
|
||||
.el-step__title.is-process,
|
||||
.el-step__title.is-finish,
|
||||
.el-step__icon.is-process,
|
||||
.el-step__icon.is-finish {
|
||||
color: var(--tone-900) !important;
|
||||
border-color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-step__head.is-process,
|
||||
.el-step__head.is-finish,
|
||||
.el-step__line-inner {
|
||||
border-color: var(--tone-900) !important;
|
||||
background-color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.el-empty__description p {
|
||||
color: var(--tone-500) !important;
|
||||
}
|
||||
|
||||
.el-rate__icon.is-active {
|
||||
color: var(--tone-800) !important;
|
||||
}
|
||||
|
||||
.text-red-500,
|
||||
.text-blue-500,
|
||||
.text-green-500,
|
||||
.text-orange-500,
|
||||
.text-purple-500,
|
||||
.text-pink-500,
|
||||
.text-rose-500,
|
||||
.text-yellow-500,
|
||||
.text-emerald-500,
|
||||
.text-blue-700,
|
||||
.text-blue-900 {
|
||||
color: var(--tone-700) !important;
|
||||
}
|
||||
|
||||
.bg-red-50,
|
||||
.bg-blue-50,
|
||||
.bg-yellow-50,
|
||||
.bg-orange-50,
|
||||
.bg-green-50,
|
||||
.bg-purple-50,
|
||||
.bg-pink-50,
|
||||
.bg-rose-50,
|
||||
.bg-emerald-50,
|
||||
.bg-blue-100,
|
||||
.bg-emerald-100,
|
||||
.bg-orange-100,
|
||||
.bg-rose-100 {
|
||||
background-color: var(--surface-muted) !important;
|
||||
}
|
||||
|
||||
.from-red-500,
|
||||
.from-orange-400,
|
||||
.from-green-400,
|
||||
.from-purple-400,
|
||||
.from-yellow-400,
|
||||
.from-blue-500,
|
||||
.from-blue-400,
|
||||
.from-pink-500 {
|
||||
--tw-gradient-from: #ffffff var(--tw-gradient-from-position) !important;
|
||||
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
.to-red-500,
|
||||
.to-blue-500,
|
||||
.to-pink-500,
|
||||
.to-orange-500,
|
||||
.to-blue-400,
|
||||
.to-orange-400 {
|
||||
--tw-gradient-to: #ffffff var(--tw-gradient-to-position) !important;
|
||||
}
|
||||
|
||||
.border-primary-500 {
|
||||
border-color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.hover\:text-primary-500:hover,
|
||||
.hover\:text-blue-500:hover,
|
||||
.hover\:text-red-500:hover {
|
||||
color: var(--tone-900) !important;
|
||||
}
|
||||
|
||||
.mini-stat,
|
||||
.stat-card,
|
||||
.panel-card,
|
||||
.feature-card,
|
||||
.price-card,
|
||||
.note-card,
|
||||
.rules-card,
|
||||
.service-row,
|
||||
.business-item,
|
||||
.log-row,
|
||||
.brand-icon,
|
||||
.brand-tag,
|
||||
.cart-link,
|
||||
.user-trigger,
|
||||
.notification-center .notification-trigger,
|
||||
.discount-badge,
|
||||
.discount-pill,
|
||||
.time-block {
|
||||
background: var(--surface-raised) !important;
|
||||
color: var(--tone-900) !important;
|
||||
border: 1px solid var(--line-soft) !important;
|
||||
box-shadow: var(--shadow-soft) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-stat__value,
|
||||
.mini-stat__label,
|
||||
.stat-value,
|
||||
.stat-label,
|
||||
.stat-desc,
|
||||
.panel-title,
|
||||
.panel-subtitle {
|
||||
color: var(--tone-900) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
background: var(--surface-muted) !important;
|
||||
color: var(--tone-900) !important;
|
||||
border: 1px solid var(--line-soft) !important;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--tone-500) !important;
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
color: var(--tone-900);
|
||||
font-weight: 700;
|
||||
}
|
||||
61
flash-sale-frontend/src/types/api.d.ts
vendored
61
flash-sale-frontend/src/types/api.d.ts
vendored
@@ -156,3 +156,64 @@ export interface Statistics {
|
||||
activeFlashSales: number
|
||||
onlineUsers: number
|
||||
}
|
||||
|
||||
// 拼团活动类型
|
||||
export interface GroupBuying {
|
||||
id: number
|
||||
productId: number
|
||||
productName: string
|
||||
productImageUrl: string
|
||||
productPrice: number
|
||||
groupPrice: number
|
||||
requiredMembers: number
|
||||
durationMinutes: number
|
||||
totalStock: number
|
||||
remainingStock: number
|
||||
maxPerUser: number
|
||||
status: 'DRAFT' | 'UPCOMING' | 'ACTIVE' | 'ENDED'
|
||||
statusDescription: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
activeGroupCount: number
|
||||
discount: number
|
||||
}
|
||||
|
||||
// 拼团团组类型
|
||||
export interface GroupBuyingGroup {
|
||||
id: number
|
||||
groupNo: string
|
||||
groupBuyingId: number
|
||||
leaderUserId: number
|
||||
leaderUsername: string
|
||||
requiredMembers: number
|
||||
currentMembers: number
|
||||
status: 'FORMING' | 'SUCCESS' | 'FAILED'
|
||||
statusDescription: string
|
||||
expireTime: string
|
||||
createdAt: string
|
||||
completedAt?: string
|
||||
members: GroupBuyingMember[]
|
||||
groupBuying?: GroupBuying
|
||||
}
|
||||
|
||||
// 拼团成员类型
|
||||
export interface GroupBuyingMember {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
avatar?: string
|
||||
orderId?: number
|
||||
status: number
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
// 拼团统计
|
||||
export interface GroupBuyingStatistics {
|
||||
totalActivities: number
|
||||
activeActivities: number
|
||||
myGroups: number
|
||||
successGroups: number
|
||||
totalSaved: number
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
CartItem,
|
||||
FlashSale,
|
||||
GroupBuying,
|
||||
GroupBuyingGroup,
|
||||
Order,
|
||||
OrderAddress,
|
||||
PageResponse,
|
||||
@@ -285,3 +287,69 @@ export const normalizeAdminProduct = (product: Record<string, any>): AdminProduc
|
||||
viewCount: toNumber(product.viewCount),
|
||||
rating: toNumber(product.rating),
|
||||
})
|
||||
|
||||
export const mapGroupBuyingStatus = (status: number | string): GroupBuying['status'] => {
|
||||
const value = typeof status === 'string' ? status : toNumber(status)
|
||||
if (value === 'DRAFT' || value === 0) return 'DRAFT'
|
||||
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
|
||||
if (value === 'ACTIVE' || value === 2) return 'ACTIVE'
|
||||
if (value === 'ENDED' || value === 3) return 'ENDED'
|
||||
return 'DRAFT'
|
||||
}
|
||||
|
||||
export const mapGroupStatus = (status: number | string): GroupBuyingGroup['status'] => {
|
||||
const value = typeof status === 'string' ? status : toNumber(status)
|
||||
if (value === 'FORMING' || value === 1) return 'FORMING'
|
||||
if (value === 'SUCCESS' || value === 2) return 'SUCCESS'
|
||||
if (value === 'FAILED' || value === 3) return 'FAILED'
|
||||
return 'FORMING'
|
||||
}
|
||||
|
||||
export const normalizeGroupBuying = (gb: Record<string, any>): GroupBuying => ({
|
||||
id: toNumber(gb.id),
|
||||
productId: toNumber(gb.productId),
|
||||
productName: toString(gb.productName),
|
||||
productImageUrl: resolveImageUrl(toString(gb.productImageUrl, '')),
|
||||
productPrice: toNumber(gb.productPrice),
|
||||
groupPrice: toNumber(gb.groupPrice),
|
||||
requiredMembers: toNumber(gb.requiredMembers, 2),
|
||||
durationMinutes: toNumber(gb.durationMinutes, 1440),
|
||||
totalStock: toNumber(gb.totalStock),
|
||||
remainingStock: toNumber(gb.remainingStock),
|
||||
maxPerUser: toNumber(gb.maxPerUser, 1),
|
||||
status: mapGroupBuyingStatus(gb.status),
|
||||
statusDescription: toString(gb.statusDescription),
|
||||
startTime: toIsoLikeString(gb.startTime),
|
||||
endTime: toIsoLikeString(gb.endTime),
|
||||
createdAt: toIsoLikeString(gb.createdAt),
|
||||
updatedAt: toIsoLikeString(gb.updatedAt || gb.createdAt),
|
||||
activeGroupCount: toNumber(gb.activeGroupCount),
|
||||
discount: toNumber(gb.discount),
|
||||
})
|
||||
|
||||
export const normalizeGroupBuyingGroup = (group: Record<string, any>): GroupBuyingGroup => ({
|
||||
id: toNumber(group.id),
|
||||
groupNo: toString(group.groupNo),
|
||||
groupBuyingId: toNumber(group.groupBuyingId),
|
||||
leaderUserId: toNumber(group.leaderUserId),
|
||||
leaderUsername: toString(group.leaderUsername),
|
||||
requiredMembers: toNumber(group.requiredMembers, 2),
|
||||
currentMembers: toNumber(group.currentMembers, 1),
|
||||
status: mapGroupStatus(group.status),
|
||||
statusDescription: toString(group.statusDescription),
|
||||
expireTime: toIsoLikeString(group.expireTime),
|
||||
createdAt: toIsoLikeString(group.createdAt),
|
||||
completedAt: group.completedAt ? toIsoLikeString(group.completedAt) : undefined,
|
||||
members: Array.isArray(group.members)
|
||||
? group.members.map((m: Record<string, any>) => ({
|
||||
id: toNumber(m.id),
|
||||
userId: toNumber(m.userId),
|
||||
username: toString(m.username),
|
||||
avatar: resolveImageUrl(toString(m.avatar, '')),
|
||||
orderId: m.orderId ? toNumber(m.orderId) : undefined,
|
||||
status: toNumber(m.status),
|
||||
joinedAt: toIsoLikeString(m.joinedAt),
|
||||
}))
|
||||
: [],
|
||||
groupBuying: group.groupBuying ? normalizeGroupBuying(group.groupBuying) : undefined,
|
||||
})
|
||||
|
||||
@@ -8,16 +8,16 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
50: '#f7f7f6',
|
||||
100: '#efefed',
|
||||
200: '#dfdfdc',
|
||||
300: '#c6c6c2',
|
||||
400: '#9f9f99',
|
||||
500: '#7b7b74',
|
||||
600: '#5e5e58',
|
||||
700: '#44443f',
|
||||
800: '#2b2b27',
|
||||
900: '#171715',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
|
||||
@@ -31,6 +31,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.org.flashsalesystem;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class FlashSaleSystemApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -292,4 +292,16 @@ public class RedissonConfig {
|
||||
log.info("加载购物车操作Lua脚本");
|
||||
return script;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼团库存扣减Lua脚本
|
||||
*/
|
||||
@Bean
|
||||
public DefaultRedisScript<Long> groupBuyingStockScript() {
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/groupbuying_stock.lua")));
|
||||
script.setResultType(Long.class);
|
||||
log.info("加载拼团库存扣减Lua脚本");
|
||||
return script;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,31 @@ public class FlashSaleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取秒杀活动统计信息
|
||||
*/
|
||||
@GetMapping("/statistics")
|
||||
public ResponseEntity<Map<String, Object>> getFlashSaleStatistics(HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
Map<String, Object> stats = flashSaleService.getFlashSaleStatistics(userId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", stats);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取秒杀统计失败", e);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", e.getMessage());
|
||||
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取秒杀活动详情
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.org.flashsalesystem.controller;
|
||||
|
||||
import com.org.flashsalesystem.dto.GroupBuyingDTO;
|
||||
import com.org.flashsalesystem.dto.UserDTO;
|
||||
import com.org.flashsalesystem.service.GroupBuyingService;
|
||||
import com.org.flashsalesystem.service.UserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "拼团管理", description = "拼团活动创建、参与、团组管理等接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/groupbuying")
|
||||
@Slf4j
|
||||
public class GroupBuyingController {
|
||||
|
||||
@Autowired
|
||||
private GroupBuyingService groupBuyingService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
// ========== 用户端 ==========
|
||||
|
||||
@Operation(summary = "获取拼团活动列表")
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<Map<String, Object>> getList(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
try {
|
||||
Map<String, Object> result = groupBuyingService.getGroupBuyingList(page, size, status);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取拼团活动列表失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取拼团活动详情")
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> getDetail(@PathVariable Long id) {
|
||||
try {
|
||||
GroupBuyingDTO detail = groupBuyingService.getGroupBuyingDetail(id);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", detail);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取拼团活动详情失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取活动下的团组列表")
|
||||
@GetMapping("/{id}/groups")
|
||||
public ResponseEntity<Map<String, Object>> getGroups(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
try {
|
||||
Map<String, Object> result = groupBuyingService.getGroupsByActivity(id, page, size);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取团组列表失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "参与拼团")
|
||||
@PostMapping("/join")
|
||||
public ResponseEntity<Map<String, Object>> joinGroup(
|
||||
@Validated @RequestBody GroupBuyingDTO.JoinGroupDTO joinDTO,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return createUnauthorizedResponse();
|
||||
}
|
||||
|
||||
GroupBuyingDTO.JoinResultDTO result = groupBuyingService.joinGroupBuying(joinDTO, userId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", result.getSuccess());
|
||||
response.put("message", result.getMessage());
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("参与拼团失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取团组详情")
|
||||
@GetMapping("/group/{groupId}")
|
||||
public ResponseEntity<Map<String, Object>> getGroupDetail(@PathVariable Long groupId) {
|
||||
try {
|
||||
GroupBuyingDTO.GroupInfoDTO detail = groupBuyingService.getGroupDetail(groupId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", detail);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取团组详情失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "退出团组")
|
||||
@PostMapping("/group/{groupId}/cancel")
|
||||
public ResponseEntity<Map<String, Object>> cancelMembership(
|
||||
@PathVariable Long groupId,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return createUnauthorizedResponse();
|
||||
}
|
||||
|
||||
groupBuyingService.cancelMembership(groupId, userId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "已退出团组");
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("退出团组失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "我的团组")
|
||||
@GetMapping("/my-groups")
|
||||
public ResponseEntity<Map<String, Object>> getMyGroups(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return createUnauthorizedResponse();
|
||||
}
|
||||
|
||||
Map<String, Object> result = groupBuyingService.getMyGroups(userId, page, size);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取我的团组失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "拼团统计数据")
|
||||
@GetMapping("/statistics")
|
||||
public ResponseEntity<Map<String, Object>> getStatistics(HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
GroupBuyingDTO.StatisticsDTO stats = groupBuyingService.getStatistics(userId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", stats);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("获取拼团统计失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 管理员端 ==========
|
||||
|
||||
@Operation(summary = "创建拼团活动")
|
||||
@PostMapping("/admin/create")
|
||||
public ResponseEntity<Map<String, Object>> create(
|
||||
@Validated @RequestBody GroupBuyingDTO.CreateDTO createDTO) {
|
||||
try {
|
||||
GroupBuyingDTO result = groupBuyingService.createGroupBuying(createDTO);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "拼团活动创建成功");
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("创建拼团活动失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "更新拼团活动")
|
||||
@PutMapping("/admin/{id}")
|
||||
public ResponseEntity<Map<String, Object>> update(
|
||||
@PathVariable Long id,
|
||||
@Validated @RequestBody GroupBuyingDTO.UpdateDTO updateDTO) {
|
||||
try {
|
||||
GroupBuyingDTO result = groupBuyingService.updateGroupBuying(id, updateDTO);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "拼团活动更新成功");
|
||||
response.put("data", result);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("更新拼团活动失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "删除拼团活动")
|
||||
@DeleteMapping("/admin/{id}")
|
||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
try {
|
||||
boolean success = groupBuyingService.deleteGroupBuying(id);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", success);
|
||||
response.put("message", success ? "拼团活动删除成功" : "拼团活动删除失败");
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("删除拼团活动失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "预热所有拼团活动库存")
|
||||
@PostMapping("/admin/preload-all")
|
||||
public ResponseEntity<Map<String, Object>> preloadAll() {
|
||||
try {
|
||||
groupBuyingService.preloadAllActiveStock();
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "拼团活动库存预热完成");
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("预热拼团库存失败", e);
|
||||
return badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 工具方法 ==========
|
||||
|
||||
private Long getCurrentUserId(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) return null;
|
||||
String token = (String) session.getAttribute("token");
|
||||
UserDTO user = userService.getUserByToken(token);
|
||||
return user != null ? user.getId() : null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> createUnauthorizedResponse() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录或登录已过期");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> badRequest(String message) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", message);
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.org.flashsalesystem.controller;
|
||||
|
||||
import com.org.flashsalesystem.dto.UserDTO;
|
||||
import com.org.flashsalesystem.service.NotificationService;
|
||||
import com.org.flashsalesystem.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/notification")
|
||||
public class NotificationController {
|
||||
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<Map<String, Object>> getNotifications(
|
||||
@RequestParam(required = false) String type,
|
||||
HttpServletRequest request) {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
if (type != null && !type.isEmpty()) {
|
||||
response.put("data", notificationService.getUserNotificationsByType(userId, type));
|
||||
} else {
|
||||
response.put("data", notificationService.getUserNotifications(userId));
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public ResponseEntity<Map<String, Object>> getUnreadCount(HttpServletRequest request) {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", notificationService.getUnreadCount(userId));
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/read")
|
||||
public ResponseEntity<Map<String, Object>> markAsRead(@PathVariable Long id,
|
||||
HttpServletRequest request) {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
notificationService.markAsRead(id, userId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "已标记为已读");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PutMapping("/read-all")
|
||||
public ResponseEntity<Map<String, Object>> markAllAsRead(HttpServletRequest request) {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
notificationService.markAllAsRead(userId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "已全部标记为已读");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@DeleteMapping("/clear")
|
||||
public ResponseEntity<Map<String, Object>> clearAll(HttpServletRequest request) {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
notificationService.clearAll(userId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "已清空所有通知");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
String token = (String) session.getAttribute("token");
|
||||
UserDTO user = userService.getUserByToken(token);
|
||||
return user != null ? user.getId() : null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> unauthorized() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录或登录已过期");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -42,10 +43,52 @@ public class ProductReviewController {
|
||||
}
|
||||
|
||||
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<>();
|
||||
response.put("success", true);
|
||||
response.put("data", productReviewService.checkReviewStatus(orderId, productId));
|
||||
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);
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(HttpServletRequest request) {
|
||||
|
||||
173
src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java
Normal file
173
src/main/java/com/org/flashsalesystem/dto/GroupBuyingDTO.java
Normal file
@@ -0,0 +1,173 @@
|
||||
package com.org.flashsalesystem.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GroupBuyingDTO {
|
||||
|
||||
private Long id;
|
||||
private Long productId;
|
||||
private String productName;
|
||||
private String productImageUrl;
|
||||
private BigDecimal productPrice;
|
||||
private BigDecimal groupPrice;
|
||||
private Integer requiredMembers;
|
||||
private Integer durationMinutes;
|
||||
private Integer totalStock;
|
||||
private Integer remainingStock;
|
||||
private Integer maxPerUser;
|
||||
private Integer status;
|
||||
private String statusDescription;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime endTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
private Integer activeGroupCount;
|
||||
private BigDecimal discount;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class CreateDTO {
|
||||
@NotNull(message = "商品ID不能为空")
|
||||
private Long productId;
|
||||
|
||||
@NotNull(message = "拼团价格不能为空")
|
||||
@DecimalMin(value = "0.01", message = "拼团价格必须大于0")
|
||||
private BigDecimal groupPrice;
|
||||
|
||||
@Min(value = 2, message = "成团人数至少为2人")
|
||||
private Integer requiredMembers = 2;
|
||||
|
||||
@Min(value = 1, message = "有效期至少为1分钟")
|
||||
private Integer durationMinutes = 1440;
|
||||
|
||||
@Min(value = 1, message = "总库存至少为1")
|
||||
private Integer totalStock;
|
||||
|
||||
@Min(value = 1, message = "每人限购至少为1")
|
||||
private Integer maxPerUser = 1;
|
||||
|
||||
@NotNull(message = "开始时间不能为空")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@NotNull(message = "结束时间不能为空")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime endTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class UpdateDTO {
|
||||
private Long productId;
|
||||
private BigDecimal groupPrice;
|
||||
private Integer requiredMembers;
|
||||
private Integer durationMinutes;
|
||||
private Integer totalStock;
|
||||
private Integer maxPerUser;
|
||||
private Integer status;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime endTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class JoinGroupDTO {
|
||||
@NotNull(message = "拼团活动ID不能为空")
|
||||
private Long groupBuyingId;
|
||||
|
||||
private Long groupId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class GroupInfoDTO {
|
||||
private Long id;
|
||||
private String groupNo;
|
||||
private Long groupBuyingId;
|
||||
private Long leaderUserId;
|
||||
private String leaderUsername;
|
||||
private Integer requiredMembers;
|
||||
private Integer currentMembers;
|
||||
private Integer status;
|
||||
private String statusDescription;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime expireTime;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
private List<MemberDTO> members;
|
||||
private GroupBuyingDTO groupBuying;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class MemberDTO {
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private Long orderId;
|
||||
private Integer status;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime joinedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class JoinResultDTO {
|
||||
private Boolean success;
|
||||
private String message;
|
||||
private Long groupId;
|
||||
private String groupNo;
|
||||
private Long orderId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class StatisticsDTO {
|
||||
private Long totalActivities;
|
||||
private Long activeActivities;
|
||||
private Long myGroups;
|
||||
private Long successGroups;
|
||||
private BigDecimal totalSaved;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class OrderDTO {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private Long productId;
|
||||
private Long flashSaleId;
|
||||
private String productName;
|
||||
private String productImageUrl;
|
||||
private Integer quantity;
|
||||
|
||||
@@ -20,6 +20,8 @@ public class ProductReviewDTO {
|
||||
private Long userId;
|
||||
private Long orderId;
|
||||
private String username;
|
||||
private String productName;
|
||||
private String productImage;
|
||||
private Integer rating;
|
||||
private String content;
|
||||
private Integer status;
|
||||
@@ -64,4 +66,12 @@ public class ProductReviewDTO {
|
||||
private Integer status;
|
||||
private String adminReply;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class CheckDTO {
|
||||
private boolean reviewed;
|
||||
private ProductReviewDTO review;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.org.flashsalesystem.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "group_buying")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GroupBuying {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "product_id", nullable = false)
|
||||
private Long productId;
|
||||
|
||||
@Column(name = "group_price", nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal groupPrice;
|
||||
|
||||
@Column(name = "required_members", nullable = false)
|
||||
private Integer requiredMembers = 2;
|
||||
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 1440;
|
||||
|
||||
@Column(name = "total_stock", nullable = false)
|
||||
private Integer totalStock;
|
||||
|
||||
@Column(name = "remaining_stock", nullable = false)
|
||||
private Integer remainingStock;
|
||||
|
||||
@Column(name = "max_per_user", nullable = false)
|
||||
private Integer maxPerUser = 1;
|
||||
|
||||
/**
|
||||
* 状态:0-草稿 1-未开始 2-进行中 3-已结束
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Integer status = 0;
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@Column(name = "end_time", nullable = false)
|
||||
private LocalDateTime endTime;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "product_id", insertable = false, updatable = false)
|
||||
private Product product;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return now.isAfter(startTime) && now.isBefore(endTime) && status == 2;
|
||||
}
|
||||
|
||||
public enum GroupBuyingStatus {
|
||||
DRAFT(0, "草稿"),
|
||||
PENDING(1, "未开始"),
|
||||
ACTIVE(2, "进行中"),
|
||||
ENDED(3, "已结束");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
GroupBuyingStatus(int code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public int getCode() { return code; }
|
||||
public String getDescription() { return description; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.org.flashsalesystem.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "group_buying_group")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GroupBuyingGroup {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "group_no", nullable = false, unique = true, length = 64)
|
||||
private String groupNo;
|
||||
|
||||
@Column(name = "group_buying_id", nullable = false)
|
||||
private Long groupBuyingId;
|
||||
|
||||
@Column(name = "leader_user_id", nullable = false)
|
||||
private Long leaderUserId;
|
||||
|
||||
@Column(name = "required_members", nullable = false)
|
||||
private Integer requiredMembers;
|
||||
|
||||
@Column(name = "current_members", nullable = false)
|
||||
private Integer currentMembers = 1;
|
||||
|
||||
/**
|
||||
* 状态:1-拼团中 2-已成团 3-已失败(超时)
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
@Column(name = "expire_time", nullable = false)
|
||||
private LocalDateTime expireTime;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "group_buying_id", insertable = false, updatable = false)
|
||||
private GroupBuying groupBuying;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "leader_user_id", insertable = false, updatable = false)
|
||||
private User leader;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public enum GroupStatus {
|
||||
FORMING(1, "拼团中"),
|
||||
SUCCESS(2, "已成团"),
|
||||
FAILED(3, "已失败");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
GroupStatus(int code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public int getCode() { return code; }
|
||||
public String getDescription() { return description; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.org.flashsalesystem.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "group_buying_member", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"})
|
||||
})
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GroupBuyingMember {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "group_id", nullable = false)
|
||||
private Long groupId;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "order_id")
|
||||
private Long orderId;
|
||||
|
||||
/**
|
||||
* 状态:1-已加入 2-已成团 3-已退出
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
@Column(name = "joined_at", nullable = false, updatable = false)
|
||||
private LocalDateTime joinedAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "group_id", insertable = false, updatable = false)
|
||||
private GroupBuyingGroup group;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", insertable = false, updatable = false)
|
||||
private User user;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
joinedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.org.flashsalesystem.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "notifications", indexes = {
|
||||
@Index(name = "idx_notification_user_read", columnList = "user_id, is_read"),
|
||||
@Index(name = "idx_notification_user_created", columnList = "user_id, created_at")
|
||||
})
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Notification {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String type;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String message;
|
||||
|
||||
@Column(length = 255)
|
||||
private String link;
|
||||
|
||||
@Column(name = "is_read", nullable = false)
|
||||
private Boolean read = false;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ public class Order {
|
||||
@Column(name = "product_id", nullable = false)
|
||||
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")
|
||||
@Column(nullable = false)
|
||||
private Integer quantity;
|
||||
@@ -145,7 +151,8 @@ public class Order {
|
||||
*/
|
||||
public enum OrderType {
|
||||
NORMAL(1, "普通订单"),
|
||||
FLASH_SALE(2, "秒杀订单");
|
||||
FLASH_SALE(2, "秒杀订单"),
|
||||
GROUP_BUYING(3, "拼团订单");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 秒杀活动数据访问层
|
||||
@@ -19,16 +18,18 @@ import java.util.Optional;
|
||||
@Repository
|
||||
public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
|
||||
|
||||
/**
|
||||
* 根据商品ID查找秒杀活动
|
||||
*/
|
||||
Optional<FlashSale> findByProductId(Long productId);
|
||||
|
||||
/**
|
||||
* 分页查找指定商品的秒杀活动
|
||||
*/
|
||||
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和状态查找秒杀活动
|
||||
*/
|
||||
@@ -78,6 +79,13 @@ public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
|
||||
" >= :quantity")
|
||||
int updateFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
|
||||
|
||||
/**
|
||||
* 恢复秒杀库存(订单取消时使用)
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE FlashSale f SET f.flashStock = f.flashStock + :quantity WHERE f.id = :flashSaleId")
|
||||
int increaseFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
|
||||
|
||||
/**
|
||||
* 更新秒杀活动状态
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.org.flashsalesystem.repository;
|
||||
|
||||
import com.org.flashsalesystem.entity.GroupBuyingGroup;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface GroupBuyingGroupRepository extends JpaRepository<GroupBuyingGroup, Long> {
|
||||
|
||||
List<GroupBuyingGroup> findByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status);
|
||||
|
||||
Optional<GroupBuyingGroup> findByGroupNo(String groupNo);
|
||||
|
||||
@Query("SELECT g FROM GroupBuyingGroup g WHERE g.status = 1 AND g.expireTime < :now")
|
||||
List<GroupBuyingGroup> findExpiredGroups(@Param("now") LocalDateTime now);
|
||||
|
||||
List<GroupBuyingGroup> findByLeaderUserId(Long userId);
|
||||
|
||||
Page<GroupBuyingGroup> findByGroupBuyingId(Long groupBuyingId, Pageable pageable);
|
||||
|
||||
@Query("SELECT g FROM GroupBuyingGroup g WHERE g.id IN " +
|
||||
"(SELECT m.groupId FROM GroupBuyingMember m WHERE m.userId = :userId AND m.status != 3)")
|
||||
Page<GroupBuyingGroup> findByMemberUserId(@Param("userId") Long userId, Pageable pageable);
|
||||
|
||||
long countByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers + 1 WHERE g.id = :id")
|
||||
int incrementCurrentMembers(@Param("id") Long id);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers - 1 WHERE g.id = :id AND g.currentMembers > 0")
|
||||
int decrementCurrentMembers(@Param("id") Long id);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuyingGroup g SET g.status = :status, g.completedAt = :completedAt WHERE g.id = :id")
|
||||
int updateStatusAndCompletedAt(@Param("id") Long id, @Param("status") Integer status, @Param("completedAt") LocalDateTime completedAt);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.org.flashsalesystem.repository;
|
||||
|
||||
import com.org.flashsalesystem.entity.GroupBuyingMember;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface GroupBuyingMemberRepository extends JpaRepository<GroupBuyingMember, Long> {
|
||||
|
||||
List<GroupBuyingMember> findByGroupId(Long groupId);
|
||||
|
||||
List<GroupBuyingMember> findByGroupIdAndStatus(Long groupId, Integer status);
|
||||
|
||||
boolean existsByGroupIdAndUserIdAndStatusNot(Long groupId, Long userId, Integer excludeStatus);
|
||||
|
||||
Optional<GroupBuyingMember> findByGroupIdAndUserId(Long groupId, Long userId);
|
||||
|
||||
long countByGroupIdAndStatusNot(Long groupId, Integer excludeStatus);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuyingMember m SET m.status = :status WHERE m.groupId = :groupId AND m.status = 1")
|
||||
int updateStatusByGroupId(@Param("groupId") Long groupId, @Param("status") Integer status);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.org.flashsalesystem.repository;
|
||||
|
||||
import com.org.flashsalesystem.entity.GroupBuying;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface GroupBuyingRepository extends JpaRepository<GroupBuying, Long> {
|
||||
|
||||
@Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2")
|
||||
List<GroupBuying> findActiveGroupBuyings(@Param("now") LocalDateTime now);
|
||||
|
||||
@Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2")
|
||||
Page<GroupBuying> findActiveGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable);
|
||||
|
||||
@Query("SELECT g FROM GroupBuying g WHERE g.startTime > :now AND g.status = 1")
|
||||
List<GroupBuying> findUpcomingGroupBuyings(@Param("now") LocalDateTime now);
|
||||
|
||||
@Query("SELECT g FROM GroupBuying g WHERE g.endTime <= :now OR g.status = 3")
|
||||
Page<GroupBuying> findEndedGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable);
|
||||
|
||||
Page<GroupBuying> findByStatus(Integer status, Pageable pageable);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock - :quantity WHERE g.id = :id AND g.remainingStock >= :quantity")
|
||||
int updateStock(@Param("id") Long id, @Param("quantity") Integer quantity);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock + :quantity WHERE g.id = :id")
|
||||
int increaseStock(@Param("id") Long id, @Param("quantity") Integer quantity);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE GroupBuying g SET g.status = :status WHERE g.id = :id")
|
||||
int updateStatus(@Param("id") Long id, @Param("status") Integer status);
|
||||
|
||||
long countByStatus(Integer status);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.org.flashsalesystem.repository;
|
||||
|
||||
import com.org.flashsalesystem.entity.Notification;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface NotificationRepository extends JpaRepository<Notification, Long> {
|
||||
|
||||
List<Notification> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
List<Notification> findByUserIdAndTypeOrderByCreatedAtDesc(Long userId, String type);
|
||||
|
||||
long countByUserIdAndReadFalse(Long userId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE Notification n SET n.read = true WHERE n.userId = :userId AND n.read = false")
|
||||
int markAllAsRead(@Param("userId") Long userId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE Notification n SET n.read = true WHERE n.id = :id AND n.userId = :userId")
|
||||
int markAsRead(@Param("id") Long id, @Param("userId") Long userId);
|
||||
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
@@ -49,6 +49,23 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
|
||||
*/
|
||||
Page<Order> findByOrderType(Integer orderType, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 分页查找用户指定类型的订单
|
||||
*/
|
||||
Page<Order> 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);
|
||||
|
||||
/**
|
||||
* 检查用户是否已经购买过指定商品的秒杀
|
||||
* 检查用户是否已经参与过指定秒杀活动
|
||||
*/
|
||||
@Query("SELECT COUNT(o) > 0 FROM Order o WHERE o.userId = :userId AND o.productId = :productId AND o.orderType = 2")
|
||||
boolean existsFlashSaleOrder(@Param("userId") Long userId, @Param("productId") Long productId);
|
||||
boolean existsByUserIdAndFlashSaleIdAndOrderType(Long userId, Long flashSaleId, Integer orderType);
|
||||
|
||||
/**
|
||||
* 检查指定秒杀活动是否已有订单
|
||||
*/
|
||||
boolean existsByFlashSaleIdAndOrderType(Long flashSaleId, Integer orderType);
|
||||
|
||||
/**
|
||||
* 根据创建时间范围统计订单数量
|
||||
|
||||
@@ -18,6 +18,16 @@ public interface ProductReviewRepository extends JpaRepository<ProductReview, Lo
|
||||
|
||||
long countByProductId(Long productId);
|
||||
|
||||
long countByProductIdAndStatus(Long productId, Integer status);
|
||||
|
||||
@Query("SELECT AVG(r.rating) FROM ProductReview r WHERE r.productId = :productId")
|
||||
Double findAverageRatingByProductId(@Param("productId") Long productId);
|
||||
|
||||
List<ProductReview> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
List<ProductReview> findByOrderId(Long orderId);
|
||||
|
||||
boolean existsByOrderIdAndProductId(Long orderId, Long productId);
|
||||
|
||||
Optional<ProductReview> findByOrderIdAndProductId(Long orderId, Long productId);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@@ -318,6 +320,7 @@ public class CartService {
|
||||
/**
|
||||
* 购物车下单
|
||||
*/
|
||||
@Transactional
|
||||
public OrderDTO checkoutCart(Long userId, List<Long> productIds) {
|
||||
log.info("购物车下单: 用户ID={}, 商品IDs={}", userId, productIds);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -90,10 +91,9 @@ public class FlashSaleService {
|
||||
throw new RuntimeException("开始时间不能早于当前时间");
|
||||
}
|
||||
|
||||
// 检查是否已有该商品的秒杀活动
|
||||
Optional<FlashSale> existingFlashSale = flashSaleRepository.findByProductId(createDTO.getProductId());
|
||||
if (existingFlashSale.isPresent()) {
|
||||
throw new RuntimeException("该商品已有秒杀活动");
|
||||
// 验证秒杀价格必须小于商品原价
|
||||
if (createDTO.getFlashPrice().compareTo(product.getPrice()) >= 0) {
|
||||
throw new RuntimeException("秒杀价格必须小于商品原价");
|
||||
}
|
||||
|
||||
// 创建秒杀活动
|
||||
@@ -157,8 +157,8 @@ public class FlashSaleService {
|
||||
}
|
||||
|
||||
// 检查数据库中是否已有订单
|
||||
if (orderRepository.existsFlashSaleOrder(userId, flashSale.getProductId())) {
|
||||
return createFailResult("您已经购买过该商品");
|
||||
if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) {
|
||||
return createFailResult("您已经参与过该秒杀活动");
|
||||
}
|
||||
|
||||
// 检查购买数量限制
|
||||
@@ -174,6 +174,14 @@ public class FlashSaleService {
|
||||
}
|
||||
|
||||
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 currentStock = redisService.getString(stockKey);
|
||||
@@ -300,9 +308,11 @@ public class FlashSaleService {
|
||||
// 验证排序字段
|
||||
String sortBy = validateSortField(queryDTO.getSortBy());
|
||||
|
||||
// 限制分页大小
|
||||
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||
// 构建分页和排序
|
||||
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;
|
||||
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("该秒杀活动已有订单,无法删除");
|
||||
}
|
||||
|
||||
@@ -886,6 +945,60 @@ public class FlashSaleService {
|
||||
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.setUserId(userId);
|
||||
order.setProductId(flashSale.getProductId());
|
||||
order.setFlashSaleId(flashSale.getId());
|
||||
order.setQuantity(participateDTO.getQuantity());
|
||||
order.setTotalPrice(flashSale.getFlashPrice().multiply(BigDecimal.valueOf(participateDTO.getQuantity())));
|
||||
order.setStatus(1); // 待支付
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
package com.org.flashsalesystem.service;
|
||||
|
||||
import com.org.flashsalesystem.dto.GroupBuyingDTO;
|
||||
import com.org.flashsalesystem.dto.ProductDTO;
|
||||
import com.org.flashsalesystem.dto.UserDTO;
|
||||
import com.org.flashsalesystem.entity.*;
|
||||
import com.org.flashsalesystem.repository.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class GroupBuyingService {
|
||||
|
||||
private static final String GB_STOCK_PREFIX = "groupbuying_stock:";
|
||||
private static final String GB_LOCK_PREFIX = "groupbuying_lock:";
|
||||
private static final String GB_MEMBERS_PREFIX = "groupbuying_members:";
|
||||
|
||||
@Autowired
|
||||
private GroupBuyingRepository groupBuyingRepository;
|
||||
|
||||
@Autowired
|
||||
private GroupBuyingGroupRepository groupBuyingGroupRepository;
|
||||
|
||||
@Autowired
|
||||
private GroupBuyingMemberRepository groupBuyingMemberRepository;
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Autowired
|
||||
private ProductRepository productRepository;
|
||||
|
||||
@Autowired
|
||||
private ProductService productService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
@Autowired
|
||||
private RedissonLockService redissonLockService;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("customStringRedisTemplate")
|
||||
private RedisTemplate<String, String> stringRedisTemplate;
|
||||
|
||||
@Autowired
|
||||
private DefaultRedisScript<Long> groupBuyingStockScript;
|
||||
|
||||
// ========== 管理员操作 ==========
|
||||
|
||||
@Transactional
|
||||
public GroupBuyingDTO createGroupBuying(GroupBuyingDTO.CreateDTO createDTO) {
|
||||
log.info("创建拼团活动: productId={}", createDTO.getProductId());
|
||||
|
||||
Product product = productRepository.findById(createDTO.getProductId())
|
||||
.orElseThrow(() -> new RuntimeException("商品不存在"));
|
||||
|
||||
if (createDTO.getGroupPrice().compareTo(product.getPrice()) >= 0) {
|
||||
throw new RuntimeException("拼团价格必须低于商品原价");
|
||||
}
|
||||
|
||||
if (createDTO.getEndTime().isBefore(createDTO.getStartTime())) {
|
||||
throw new RuntimeException("结束时间不能早于开始时间");
|
||||
}
|
||||
|
||||
GroupBuying gb = new GroupBuying();
|
||||
gb.setProductId(createDTO.getProductId());
|
||||
gb.setGroupPrice(createDTO.getGroupPrice());
|
||||
gb.setRequiredMembers(createDTO.getRequiredMembers());
|
||||
gb.setDurationMinutes(createDTO.getDurationMinutes());
|
||||
gb.setTotalStock(createDTO.getTotalStock());
|
||||
gb.setRemainingStock(createDTO.getTotalStock());
|
||||
gb.setMaxPerUser(createDTO.getMaxPerUser());
|
||||
gb.setStatus(0); // 草稿
|
||||
gb.setStartTime(createDTO.getStartTime());
|
||||
gb.setEndTime(createDTO.getEndTime());
|
||||
|
||||
gb = groupBuyingRepository.save(gb);
|
||||
log.info("拼团活动创建成功: id={}", gb.getId());
|
||||
|
||||
return buildDTO(gb);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GroupBuyingDTO updateGroupBuying(Long id, GroupBuyingDTO.UpdateDTO updateDTO) {
|
||||
GroupBuying gb = groupBuyingRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||
|
||||
if (gb.getStatus() == 2) {
|
||||
throw new RuntimeException("进行中的活动不能修改");
|
||||
}
|
||||
|
||||
if (updateDTO.getProductId() != null) gb.setProductId(updateDTO.getProductId());
|
||||
if (updateDTO.getGroupPrice() != null) gb.setGroupPrice(updateDTO.getGroupPrice());
|
||||
if (updateDTO.getRequiredMembers() != null) gb.setRequiredMembers(updateDTO.getRequiredMembers());
|
||||
if (updateDTO.getDurationMinutes() != null) gb.setDurationMinutes(updateDTO.getDurationMinutes());
|
||||
if (updateDTO.getTotalStock() != null) {
|
||||
int diff = updateDTO.getTotalStock() - gb.getTotalStock();
|
||||
gb.setTotalStock(updateDTO.getTotalStock());
|
||||
gb.setRemainingStock(gb.getRemainingStock() + diff);
|
||||
}
|
||||
if (updateDTO.getMaxPerUser() != null) gb.setMaxPerUser(updateDTO.getMaxPerUser());
|
||||
if (updateDTO.getStatus() != null) gb.setStatus(updateDTO.getStatus());
|
||||
if (updateDTO.getStartTime() != null) gb.setStartTime(updateDTO.getStartTime());
|
||||
if (updateDTO.getEndTime() != null) gb.setEndTime(updateDTO.getEndTime());
|
||||
|
||||
gb = groupBuyingRepository.save(gb);
|
||||
return buildDTO(gb);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteGroupBuying(Long id) {
|
||||
GroupBuying gb = groupBuyingRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||
|
||||
if (gb.getStatus() == 2) {
|
||||
throw new RuntimeException("进行中的活动不能删除");
|
||||
}
|
||||
|
||||
groupBuyingRepository.deleteById(id);
|
||||
redisService.delete(GB_STOCK_PREFIX + id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== 查询操作 ==========
|
||||
|
||||
public Map<String, Object> getGroupBuyingList(int page, int size, Integer status) {
|
||||
int pageSize = Math.min(size, 100);
|
||||
Pageable pageable = PageRequest.of(page, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
|
||||
Page<GroupBuying> gbPage;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (status != null) {
|
||||
if (status == 2) {
|
||||
gbPage = groupBuyingRepository.findActiveGroupBuyings(now, pageable);
|
||||
} else if (status == 3) {
|
||||
gbPage = groupBuyingRepository.findEndedGroupBuyings(now, pageable);
|
||||
} else {
|
||||
gbPage = groupBuyingRepository.findByStatus(status, pageable);
|
||||
}
|
||||
} else {
|
||||
gbPage = groupBuyingRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
List<GroupBuyingDTO> dtos = gbPage.getContent().stream()
|
||||
.map(this::buildDTO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("content", dtos);
|
||||
result.put("totalElements", gbPage.getTotalElements());
|
||||
result.put("totalPages", gbPage.getTotalPages());
|
||||
result.put("currentPage", gbPage.getNumber());
|
||||
result.put("size", gbPage.getSize());
|
||||
return result;
|
||||
}
|
||||
|
||||
public GroupBuyingDTO getGroupBuyingDetail(Long id) {
|
||||
GroupBuying gb = groupBuyingRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||
return buildDTO(gb);
|
||||
}
|
||||
|
||||
public Map<String, Object> getGroupsByActivity(Long groupBuyingId, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Page<GroupBuyingGroup> groupPage = groupBuyingGroupRepository.findByGroupBuyingId(groupBuyingId, pageable);
|
||||
|
||||
List<GroupBuyingDTO.GroupInfoDTO> dtos = groupPage.getContent().stream()
|
||||
.map(this::buildGroupInfoDTO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("content", dtos);
|
||||
result.put("totalElements", groupPage.getTotalElements());
|
||||
result.put("totalPages", groupPage.getTotalPages());
|
||||
result.put("currentPage", groupPage.getNumber());
|
||||
result.put("size", groupPage.getSize());
|
||||
return result;
|
||||
}
|
||||
|
||||
public GroupBuyingDTO.GroupInfoDTO getGroupDetail(Long groupId) {
|
||||
GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new RuntimeException("团组不存在"));
|
||||
return buildGroupInfoDTO(group);
|
||||
}
|
||||
|
||||
public Map<String, Object> getMyGroups(Long userId, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Page<GroupBuyingGroup> groupPage = groupBuyingGroupRepository.findByMemberUserId(userId, pageable);
|
||||
|
||||
List<GroupBuyingDTO.GroupInfoDTO> dtos = groupPage.getContent().stream()
|
||||
.map(this::buildGroupInfoDTO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("content", dtos);
|
||||
result.put("totalElements", groupPage.getTotalElements());
|
||||
result.put("totalPages", groupPage.getTotalPages());
|
||||
result.put("currentPage", groupPage.getNumber());
|
||||
result.put("size", groupPage.getSize());
|
||||
return result;
|
||||
}
|
||||
|
||||
public GroupBuyingDTO.StatisticsDTO getStatistics(Long userId) {
|
||||
GroupBuyingDTO.StatisticsDTO stats = new GroupBuyingDTO.StatisticsDTO();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
stats.setTotalActivities(groupBuyingRepository.count());
|
||||
stats.setActiveActivities(groupBuyingRepository.countByStatus(2));
|
||||
|
||||
if (userId != null) {
|
||||
Page<GroupBuyingGroup> myGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1));
|
||||
stats.setMyGroups(myGroups.getTotalElements());
|
||||
|
||||
// Count successful groups where user participated
|
||||
long successCount = 0;
|
||||
BigDecimal totalSaved = BigDecimal.ZERO;
|
||||
Page<GroupBuyingGroup> allMyGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1000));
|
||||
for (GroupBuyingGroup g : allMyGroups.getContent()) {
|
||||
if (g.getStatus() == 2) {
|
||||
successCount++;
|
||||
GroupBuying gb = groupBuyingRepository.findById(g.getGroupBuyingId()).orElse(null);
|
||||
if (gb != null) {
|
||||
Product product = productRepository.findById(gb.getProductId()).orElse(null);
|
||||
if (product != null) {
|
||||
totalSaved = totalSaved.add(product.getPrice().subtract(gb.getGroupPrice()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.setSuccessGroups(successCount);
|
||||
stats.setTotalSaved(totalSaved);
|
||||
} else {
|
||||
stats.setMyGroups(0L);
|
||||
stats.setSuccessGroups(0L);
|
||||
stats.setTotalSaved(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ========== 核心拼团逻辑 ==========
|
||||
|
||||
@Transactional
|
||||
public GroupBuyingDTO.JoinResultDTO joinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) {
|
||||
Long activityId = joinDTO.getGroupBuyingId();
|
||||
String lockKey = GB_LOCK_PREFIX + activityId;
|
||||
|
||||
if (!redissonLockService.tryLock(lockKey, 5, 30)) {
|
||||
throw new RuntimeException("系统繁忙,请稍后重试");
|
||||
}
|
||||
|
||||
try {
|
||||
return doJoinGroupBuying(joinDTO, userId);
|
||||
} finally {
|
||||
redissonLockService.unlock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupBuyingDTO.JoinResultDTO doJoinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) {
|
||||
Long activityId = joinDTO.getGroupBuyingId();
|
||||
|
||||
// 1. 校验活动状态
|
||||
GroupBuying gb = groupBuyingRepository.findById(activityId)
|
||||
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
|
||||
|
||||
if (!gb.isActive()) {
|
||||
throw new RuntimeException("拼团活动未在进行中");
|
||||
}
|
||||
|
||||
if (gb.getRemainingStock() <= 0) {
|
||||
throw new RuntimeException("库存不足");
|
||||
}
|
||||
|
||||
// 2. 获取商品信息
|
||||
Product product = productRepository.findById(gb.getProductId())
|
||||
.orElseThrow(() -> new RuntimeException("商品不存在"));
|
||||
|
||||
GroupBuyingGroup group;
|
||||
|
||||
if (joinDTO.getGroupId() != null) {
|
||||
// 加入现有团组
|
||||
group = groupBuyingGroupRepository.findById(joinDTO.getGroupId())
|
||||
.orElseThrow(() -> new RuntimeException("团组不存在"));
|
||||
|
||||
if (group.getStatus() != 1) {
|
||||
throw new RuntimeException("该团组已成团或已过期");
|
||||
}
|
||||
|
||||
if (LocalDateTime.now().isAfter(group.getExpireTime())) {
|
||||
throw new RuntimeException("该团组已过期");
|
||||
}
|
||||
|
||||
if (group.getCurrentMembers() >= group.getRequiredMembers()) {
|
||||
throw new RuntimeException("该团组已满员");
|
||||
}
|
||||
|
||||
// 检查用户是否已经在该团组中
|
||||
if (groupBuyingMemberRepository.existsByGroupIdAndUserIdAndStatusNot(group.getId(), userId, 3)) {
|
||||
throw new RuntimeException("您已在该团组中");
|
||||
}
|
||||
} else {
|
||||
// 创建新团组
|
||||
group = new GroupBuyingGroup();
|
||||
group.setGroupNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)));
|
||||
group.setGroupBuyingId(activityId);
|
||||
group.setLeaderUserId(userId);
|
||||
group.setRequiredMembers(gb.getRequiredMembers());
|
||||
group.setCurrentMembers(0); // will be incremented below
|
||||
group.setStatus(1);
|
||||
group.setExpireTime(LocalDateTime.now().plusMinutes(gb.getDurationMinutes()));
|
||||
group = groupBuyingGroupRepository.save(group);
|
||||
}
|
||||
|
||||
// 3. Redis 原子扣库存
|
||||
String stockKey = GB_STOCK_PREFIX + activityId;
|
||||
Long result = stringRedisTemplate.execute(groupBuyingStockScript,
|
||||
Collections.singletonList(stockKey), "1");
|
||||
|
||||
if (result == null || result < 0) {
|
||||
// Redis stock exhausted, double check DB
|
||||
if (gb.getRemainingStock() <= 0) {
|
||||
throw new RuntimeException("库存不足");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. DB 扣库存
|
||||
int updated = groupBuyingRepository.updateStock(activityId, 1);
|
||||
if (updated == 0) {
|
||||
// Restore Redis stock
|
||||
redisService.incrBy(stockKey, 1);
|
||||
throw new RuntimeException("库存不足");
|
||||
}
|
||||
|
||||
// 5. 创建订单
|
||||
Order order = new Order();
|
||||
order.setOrderNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)));
|
||||
order.setUserId(userId);
|
||||
order.setProductId(gb.getProductId());
|
||||
order.setQuantity(1);
|
||||
order.setTotalPrice(gb.getGroupPrice());
|
||||
order.setStatus(1); // 待支付
|
||||
order.setOrderType(3); // 拼团订单
|
||||
order.setGroupBuyingGroupId(group.getId());
|
||||
order = orderRepository.save(order);
|
||||
|
||||
// 6. 创建成员记录
|
||||
GroupBuyingMember member = new GroupBuyingMember();
|
||||
member.setGroupId(group.getId());
|
||||
member.setUserId(userId);
|
||||
member.setOrderId(order.getId());
|
||||
member.setStatus(1);
|
||||
groupBuyingMemberRepository.save(member);
|
||||
|
||||
// 7. 更新团组人数
|
||||
groupBuyingGroupRepository.incrementCurrentMembers(group.getId());
|
||||
|
||||
// Refresh group from DB
|
||||
group = groupBuyingGroupRepository.findById(group.getId()).orElse(group);
|
||||
|
||||
// 8. 检查是否满员 → 成团
|
||||
if (group.getCurrentMembers() >= group.getRequiredMembers()) {
|
||||
groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 2, LocalDateTime.now());
|
||||
// Update all members status to SUCCESS
|
||||
groupBuyingMemberRepository.updateStatusByGroupId(group.getId(), 2);
|
||||
log.info("拼团成功: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
|
||||
}
|
||||
|
||||
// Add to Redis members set
|
||||
redisService.sAdd(GB_MEMBERS_PREFIX + group.getId(), userId.toString());
|
||||
redisService.expire(GB_MEMBERS_PREFIX + group.getId(), 7, TimeUnit.DAYS);
|
||||
|
||||
GroupBuyingDTO.JoinResultDTO resultDTO = new GroupBuyingDTO.JoinResultDTO();
|
||||
resultDTO.setSuccess(true);
|
||||
resultDTO.setMessage(joinDTO.getGroupId() != null ? "加入拼团成功" : "开团成功");
|
||||
resultDTO.setGroupId(group.getId());
|
||||
resultDTO.setGroupNo(group.getGroupNo());
|
||||
resultDTO.setOrderId(order.getId());
|
||||
|
||||
log.info("用户{}参与拼团成功: activityId={}, groupId={}, orderId={}", userId, activityId, group.getId(), order.getId());
|
||||
return resultDTO;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancelMembership(Long groupId, Long userId) {
|
||||
log.info("退出拼团: groupId={}, userId={}", groupId, userId);
|
||||
|
||||
GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new RuntimeException("团组不存在"));
|
||||
|
||||
if (group.getStatus() != 1) {
|
||||
throw new RuntimeException("已成团或已失败的团组不能退出");
|
||||
}
|
||||
|
||||
GroupBuyingMember member = groupBuyingMemberRepository.findByGroupIdAndUserId(groupId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您不在该团组中"));
|
||||
|
||||
if (member.getStatus() == 3) {
|
||||
throw new RuntimeException("您已退出该团组");
|
||||
}
|
||||
|
||||
// 更新成员状态
|
||||
member.setStatus(3);
|
||||
groupBuyingMemberRepository.save(member);
|
||||
|
||||
// 更新团组人数
|
||||
groupBuyingGroupRepository.decrementCurrentMembers(groupId);
|
||||
|
||||
// 恢复库存
|
||||
Long activityId = group.getGroupBuyingId();
|
||||
groupBuyingRepository.increaseStock(activityId, 1);
|
||||
redisService.incrBy(GB_STOCK_PREFIX + activityId, 1);
|
||||
|
||||
// 取消订单
|
||||
if (member.getOrderId() != null) {
|
||||
Optional<Order> orderOpt = orderRepository.findById(member.getOrderId());
|
||||
if (orderOpt.isPresent()) {
|
||||
Order order = orderOpt.get();
|
||||
if (order.getStatus() == 1) {
|
||||
order.setStatus(5);
|
||||
order.setRemark("退出拼团,订单取消");
|
||||
orderRepository.save(order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from Redis set
|
||||
redisService.sRem(GB_MEMBERS_PREFIX + groupId, userId.toString());
|
||||
|
||||
log.info("退出拼团成功: groupId={}, userId={}", groupId, userId);
|
||||
}
|
||||
|
||||
// ========== 定时任务 ==========
|
||||
|
||||
@Scheduled(fixedRate = 60000) // 每分钟检查
|
||||
@Transactional
|
||||
public void scheduledCheckExpiredGroups() {
|
||||
List<GroupBuyingGroup> expiredGroups = groupBuyingGroupRepository.findExpiredGroups(LocalDateTime.now());
|
||||
for (GroupBuyingGroup group : expiredGroups) {
|
||||
try {
|
||||
handleExpiredGroup(group);
|
||||
} catch (Exception e) {
|
||||
log.error("处理超时团组失败: groupId={}", group.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void handleExpiredGroup(GroupBuyingGroup group) {
|
||||
log.info("处理超时团组: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
|
||||
|
||||
// Mark group as FAILED
|
||||
groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 3, LocalDateTime.now());
|
||||
|
||||
// Get active members
|
||||
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupIdAndStatus(group.getId(), 1);
|
||||
|
||||
for (GroupBuyingMember member : members) {
|
||||
// Update member status
|
||||
member.setStatus(3);
|
||||
groupBuyingMemberRepository.save(member);
|
||||
|
||||
// Restore stock
|
||||
groupBuyingRepository.increaseStock(group.getGroupBuyingId(), 1);
|
||||
redisService.incrBy(GB_STOCK_PREFIX + group.getGroupBuyingId(), 1);
|
||||
|
||||
// Cancel order
|
||||
if (member.getOrderId() != null) {
|
||||
Optional<Order> orderOpt = orderRepository.findById(member.getOrderId());
|
||||
if (orderOpt.isPresent()) {
|
||||
Order order = orderOpt.get();
|
||||
if (order.getStatus() == 1) {
|
||||
order.setStatus(5);
|
||||
order.setRemark("拼团超时,订单自动取消");
|
||||
orderRepository.save(order);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean Redis
|
||||
redisService.delete(GB_MEMBERS_PREFIX + group.getId());
|
||||
|
||||
log.info("超时团组处理完成: groupId={}", group.getId());
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 300000) // 每5分钟
|
||||
@Transactional
|
||||
public void scheduledUpdateStatus() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// Activate pending activities whose start time has passed
|
||||
List<GroupBuying> upcoming = groupBuyingRepository.findUpcomingGroupBuyings(now);
|
||||
// These are status=1 and startTime > now, so nothing to activate here
|
||||
|
||||
// Actually find activities that should be active: status=1, startTime <= now, endTime > now
|
||||
List<GroupBuying> all = groupBuyingRepository.findAll();
|
||||
for (GroupBuying gb : all) {
|
||||
if (gb.getStatus() == 1 && !now.isBefore(gb.getStartTime()) && now.isBefore(gb.getEndTime())) {
|
||||
groupBuyingRepository.updateStatus(gb.getId(), 2);
|
||||
preloadStock(gb);
|
||||
log.info("拼团活动已激活: id={}", gb.getId());
|
||||
} else if (gb.getStatus() == 2 && !now.isBefore(gb.getEndTime())) {
|
||||
groupBuyingRepository.updateStatus(gb.getId(), 3);
|
||||
log.info("拼团活动已结束: id={}", gb.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void preloadStock(GroupBuying gb) {
|
||||
String stockKey = GB_STOCK_PREFIX + gb.getId();
|
||||
redisService.setString(stockKey, String.valueOf(gb.getRemainingStock()));
|
||||
long ttl = java.time.Duration.between(LocalDateTime.now(), gb.getEndTime()).getSeconds() + 3600;
|
||||
redisService.expire(stockKey, ttl, TimeUnit.SECONDS);
|
||||
log.info("拼团库存预热: id={}, stock={}", gb.getId(), gb.getRemainingStock());
|
||||
}
|
||||
|
||||
public void preloadAllActiveStock() {
|
||||
List<GroupBuying> activeList = groupBuyingRepository.findActiveGroupBuyings(LocalDateTime.now());
|
||||
for (GroupBuying gb : activeList) {
|
||||
preloadStock(gb);
|
||||
}
|
||||
log.info("所有拼团活动库存预热完成, count={}", activeList.size());
|
||||
}
|
||||
|
||||
// ========== 构建 DTO ==========
|
||||
|
||||
private GroupBuyingDTO buildDTO(GroupBuying gb) {
|
||||
GroupBuyingDTO dto = new GroupBuyingDTO();
|
||||
dto.setId(gb.getId());
|
||||
dto.setProductId(gb.getProductId());
|
||||
dto.setGroupPrice(gb.getGroupPrice());
|
||||
dto.setRequiredMembers(gb.getRequiredMembers());
|
||||
dto.setDurationMinutes(gb.getDurationMinutes());
|
||||
dto.setTotalStock(gb.getTotalStock());
|
||||
dto.setRemainingStock(gb.getRemainingStock());
|
||||
dto.setMaxPerUser(gb.getMaxPerUser());
|
||||
dto.setStatus(gb.getStatus());
|
||||
dto.setStatusDescription(getStatusDescription(gb.getStatus()));
|
||||
dto.setStartTime(gb.getStartTime());
|
||||
dto.setEndTime(gb.getEndTime());
|
||||
dto.setCreatedAt(gb.getCreatedAt());
|
||||
dto.setUpdatedAt(gb.getUpdatedAt());
|
||||
|
||||
// Product info
|
||||
ProductDTO product = productService.getProductById(gb.getProductId());
|
||||
if (product != null) {
|
||||
dto.setProductName(product.getName());
|
||||
dto.setProductImageUrl(product.getImageUrl());
|
||||
dto.setProductPrice(product.getPrice());
|
||||
dto.setDiscount(product.getPrice().subtract(gb.getGroupPrice()));
|
||||
}
|
||||
|
||||
// Active group count
|
||||
long activeGroups = groupBuyingGroupRepository.countByGroupBuyingIdAndStatus(gb.getId(), 1);
|
||||
dto.setActiveGroupCount((int) activeGroups);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private GroupBuyingDTO.GroupInfoDTO buildGroupInfoDTO(GroupBuyingGroup group) {
|
||||
GroupBuyingDTO.GroupInfoDTO dto = new GroupBuyingDTO.GroupInfoDTO();
|
||||
dto.setId(group.getId());
|
||||
dto.setGroupNo(group.getGroupNo());
|
||||
dto.setGroupBuyingId(group.getGroupBuyingId());
|
||||
dto.setLeaderUserId(group.getLeaderUserId());
|
||||
dto.setRequiredMembers(group.getRequiredMembers());
|
||||
dto.setCurrentMembers(group.getCurrentMembers());
|
||||
dto.setStatus(group.getStatus());
|
||||
dto.setStatusDescription(getGroupStatusDescription(group.getStatus()));
|
||||
dto.setExpireTime(group.getExpireTime());
|
||||
dto.setCreatedAt(group.getCreatedAt());
|
||||
dto.setCompletedAt(group.getCompletedAt());
|
||||
|
||||
// Leader info
|
||||
UserDTO leader = userService.getUserById(group.getLeaderUserId());
|
||||
if (leader != null) {
|
||||
dto.setLeaderUsername(leader.getUsername());
|
||||
}
|
||||
|
||||
// Members
|
||||
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupId(group.getId());
|
||||
List<GroupBuyingDTO.MemberDTO> memberDTOs = members.stream()
|
||||
.filter(m -> m.getStatus() != 3) // exclude exited
|
||||
.map(m -> {
|
||||
GroupBuyingDTO.MemberDTO memberDTO = new GroupBuyingDTO.MemberDTO();
|
||||
memberDTO.setId(m.getId());
|
||||
memberDTO.setUserId(m.getUserId());
|
||||
memberDTO.setOrderId(m.getOrderId());
|
||||
memberDTO.setStatus(m.getStatus());
|
||||
memberDTO.setJoinedAt(m.getJoinedAt());
|
||||
UserDTO user = userService.getUserById(m.getUserId());
|
||||
if (user != null) {
|
||||
memberDTO.setUsername(user.getUsername());
|
||||
memberDTO.setAvatar(user.getAvatar());
|
||||
}
|
||||
return memberDTO;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
dto.setMembers(memberDTOs);
|
||||
|
||||
// Activity info
|
||||
GroupBuying gb = groupBuyingRepository.findById(group.getGroupBuyingId()).orElse(null);
|
||||
if (gb != null) {
|
||||
dto.setGroupBuying(buildDTO(gb));
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String getStatusDescription(Integer status) {
|
||||
if (status == null) return "未知";
|
||||
switch (status) {
|
||||
case 0: return "草稿";
|
||||
case 1: return "未开始";
|
||||
case 2: return "进行中";
|
||||
case 3: return "已结束";
|
||||
default: return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
private String getGroupStatusDescription(Integer status) {
|
||||
if (status == null) return "未知";
|
||||
switch (status) {
|
||||
case 1: return "拼团中";
|
||||
case 2: return "已成团";
|
||||
case 3: return "已失败";
|
||||
default: return "未知";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ public class MessageListenerService {
|
||||
@Autowired
|
||||
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) {
|
||||
// 可以在这里实现:
|
||||
// 1. 发送邮件通知
|
||||
// 2. 推送消息
|
||||
// 3. 更新统计数据
|
||||
// 4. 触发其他业务流程
|
||||
if (userId == null) {
|
||||
log.warn("订单状态变更缺少用户ID: orderId={}", orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
String title;
|
||||
String message;
|
||||
String link = "/order/" + orderId;
|
||||
|
||||
switch (action) {
|
||||
case "created":
|
||||
log.info("订单创建通知处理: 订单ID={}", orderId);
|
||||
title = "订单创建成功";
|
||||
message = "您的订单 #" + orderId + " 已创建,请尽快完成支付";
|
||||
break;
|
||||
case "paid":
|
||||
log.info("订单支付通知处理: 订单ID={}", orderId);
|
||||
title = "订单支付成功";
|
||||
message = "您的订单 #" + orderId + " 已支付成功,等待商家发货";
|
||||
break;
|
||||
case "shipped":
|
||||
log.info("订单发货通知处理: 订单ID={}", orderId);
|
||||
title = "订单已发货";
|
||||
message = "您的订单 #" + orderId + " 已发货,请注意查收";
|
||||
break;
|
||||
case "completed":
|
||||
log.info("订单完成通知处理: 订单ID={}", orderId);
|
||||
title = "订单已完成";
|
||||
message = "您的订单 #" + orderId + " 已完成,欢迎评价";
|
||||
break;
|
||||
case "cancelled":
|
||||
log.info("订单取消通知处理: 订单ID={}", orderId);
|
||||
title = "订单已取消";
|
||||
message = "您的订单 #" + orderId + " 已取消";
|
||||
break;
|
||||
default:
|
||||
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) {
|
||||
// 可以在这里实现:
|
||||
// 1. 实时通知用户
|
||||
// 2. 统计秒杀数据
|
||||
// 3. 风控分析
|
||||
// 4. 营销推荐
|
||||
if (userId == null) {
|
||||
log.warn("秒杀结果缺少用户ID: flashSaleId={}", flashSaleId);
|
||||
return;
|
||||
}
|
||||
|
||||
String link = "/flashsale/" + flashSaleId;
|
||||
|
||||
if (success) {
|
||||
log.info("秒杀成功处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
||||
// 发送成功通知
|
||||
sendFlashSaleSuccessNotification(userId, flashSaleId);
|
||||
String title = "秒杀成功";
|
||||
String message = "恭喜您成功抢购秒杀商品,请尽快完成支付!";
|
||||
notificationService.createNotification(userId, "flashsale", title, message, link);
|
||||
log.info("秒杀成功通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
||||
} else {
|
||||
log.info("秒杀失败处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
|
||||
// 可以推荐其他商品
|
||||
recommendAlternativeProducts(userId, flashSaleId);
|
||||
String title = "秒杀未中";
|
||||
String message = "很遗憾,本次秒杀未能抢购成功,下次再来吧!";
|
||||
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) {
|
||||
// 实现库存预警逻辑
|
||||
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值
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.org.flashsalesystem.service;
|
||||
|
||||
import com.org.flashsalesystem.entity.Notification;
|
||||
import com.org.flashsalesystem.repository.NotificationRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class NotificationService {
|
||||
|
||||
@Autowired
|
||||
private NotificationRepository notificationRepository;
|
||||
|
||||
public void createNotification(Long userId, String type, String title, String message, String link) {
|
||||
Notification notification = new Notification();
|
||||
notification.setUserId(userId);
|
||||
notification.setType(type);
|
||||
notification.setTitle(title);
|
||||
notification.setMessage(message);
|
||||
notification.setLink(link);
|
||||
notification.setRead(false);
|
||||
notificationRepository.save(notification);
|
||||
log.debug("通知已创建: userId={}, type={}, title={}", userId, type, title);
|
||||
}
|
||||
|
||||
public List<Notification> getUserNotifications(Long userId) {
|
||||
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
public List<Notification> getUserNotificationsByType(Long userId, String type) {
|
||||
return notificationRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, type);
|
||||
}
|
||||
|
||||
public long getUnreadCount(Long userId) {
|
||||
return notificationRepository.countByUserIdAndReadFalse(userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markAsRead(Long notificationId, Long userId) {
|
||||
notificationRepository.markAsRead(notificationId, userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markAllAsRead(Long userId) {
|
||||
notificationRepository.markAllAsRead(userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void clearAll(Long userId) {
|
||||
notificationRepository.deleteByUserId(userId);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ public class OrderService {
|
||||
private UserService userService;
|
||||
@Autowired
|
||||
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) {
|
||||
// 限制分页大小
|
||||
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||
// 构建分页和排序
|
||||
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;
|
||||
|
||||
// 根据查询条件获取订单
|
||||
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);
|
||||
} else {
|
||||
orderPage = orderRepository.findByUserId(userId, pageable);
|
||||
@@ -259,9 +269,11 @@ public class OrderService {
|
||||
* 获取所有订单列表(管理员)
|
||||
*/
|
||||
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());
|
||||
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
|
||||
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
|
||||
|
||||
Page<Order> orderPage;
|
||||
|
||||
@@ -402,6 +414,21 @@ public class OrderService {
|
||||
// 恢复库存
|
||||
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);
|
||||
|
||||
@@ -569,6 +596,7 @@ public class OrderService {
|
||||
orderMap.put("groupNo", order.getGroupNo() == null ? "" : order.getGroupNo());
|
||||
orderMap.put("userId", order.getUserId().toString());
|
||||
orderMap.put("productId", order.getProductId().toString());
|
||||
orderMap.put("flashSaleId", order.getFlashSaleId() == null ? "" : order.getFlashSaleId().toString());
|
||||
orderMap.put("quantity", order.getQuantity().toString());
|
||||
orderMap.put("totalPrice", order.getTotalPrice().toString());
|
||||
orderMap.put("status", order.getStatus().toString());
|
||||
@@ -603,6 +631,8 @@ public class OrderService {
|
||||
orderDTO.setGroupNo((String) orderMap.get("groupNo"));
|
||||
orderDTO.setUserId(Long.valueOf((String) orderMap.get("userId")));
|
||||
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.setTotalPrice(new BigDecimal((String) orderMap.get("totalPrice")));
|
||||
orderDTO.setStatus(Integer.valueOf((String) orderMap.get("status")));
|
||||
@@ -849,6 +879,8 @@ public class OrderService {
|
||||
return "普通订单";
|
||||
case 2:
|
||||
return "秒杀订单";
|
||||
case 3:
|
||||
return "拼团订单";
|
||||
default:
|
||||
return "未知类型";
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.org.flashsalesystem.dto.ProductReviewDTO;
|
||||
import com.org.flashsalesystem.dto.UserDTO;
|
||||
import com.org.flashsalesystem.entity.Order;
|
||||
import com.org.flashsalesystem.entity.OrderItem;
|
||||
import com.org.flashsalesystem.entity.Product;
|
||||
import com.org.flashsalesystem.entity.ProductReview;
|
||||
import com.org.flashsalesystem.repository.OrderItemRepository;
|
||||
import com.org.flashsalesystem.repository.OrderRepository;
|
||||
import com.org.flashsalesystem.repository.ProductRepository;
|
||||
import com.org.flashsalesystem.repository.ProductReviewRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -15,6 +17,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -30,6 +33,9 @@ public class ProductReviewService {
|
||||
@Autowired
|
||||
private OrderItemRepository orderItemRepository;
|
||||
|
||||
@Autowired
|
||||
private ProductRepository productRepository;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@@ -68,7 +74,7 @@ public class ProductReviewService {
|
||||
.map(this::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -89,12 +95,39 @@ public class ProductReviewService {
|
||||
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) {
|
||||
ProductReviewDTO dto = new ProductReviewDTO();
|
||||
BeanUtils.copyProperties(review, dto);
|
||||
UserDTO user = userService.getUserById(review.getUserId());
|
||||
dto.setUsername(user != null ? user.getUsername() : "匿名用户");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,11 @@ public class ProductService {
|
||||
return (Map<String, Object>) cachedResult;
|
||||
}
|
||||
|
||||
// 限制分页大小
|
||||
int pageSize = Math.min(queryDTO.getSize(), 100);
|
||||
// 构建分页和排序
|
||||
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;
|
||||
String keyword = queryDTO.getKeyword() != null && !queryDTO.getKeyword().trim().isEmpty()
|
||||
|
||||
@@ -2,6 +2,8 @@ server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 30m
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
32
src/main/resources/lua/groupbuying_stock.lua
Normal file
32
src/main/resources/lua/groupbuying_stock.lua
Normal file
@@ -0,0 +1,32 @@
|
||||
-- 拼团库存扣减Lua脚本
|
||||
-- 功能:原子性地检查库存并扣减,防止超卖
|
||||
-- 参数:KEYS[1] = 库存key, ARGV[1] = 扣减数量
|
||||
-- 返回值:成功返回剩余库存,失败返回负数
|
||||
|
||||
local stock_key = KEYS[1]
|
||||
local quantity_str = ARGV[1]
|
||||
local quantity = tonumber(quantity_str)
|
||||
|
||||
if quantity == nil or quantity <= 0 then
|
||||
return -3
|
||||
end
|
||||
|
||||
local current_stock = redis.call('GET', stock_key)
|
||||
|
||||
if current_stock == false then
|
||||
return -1
|
||||
end
|
||||
|
||||
local current_stock_num = tonumber(current_stock)
|
||||
|
||||
if current_stock_num == nil then
|
||||
return -1
|
||||
end
|
||||
|
||||
if current_stock_num < quantity then
|
||||
return -2
|
||||
end
|
||||
|
||||
local remaining_stock = redis.call('DECRBY', stock_key, quantity)
|
||||
|
||||
return remaining_stock
|
||||
@@ -1,22 +1,17 @@
|
||||
-- 演示账号快速创建脚本
|
||||
-- 密码都是明文对应的值:demo1/demo2/admin的密码分别是123456/123456/admin123
|
||||
-- 演示账号初始化脚本
|
||||
-- 账号:demo1 / 123456,demo2 / 123456,admin / admin123
|
||||
|
||||
USE flash_sale_db;
|
||||
|
||||
-- 插入演示用户(密码已加密)
|
||||
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$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1,
|
||||
NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE username = VALUES(username),
|
||||
INSERT INTO users (username, password, email, phone, avatar, 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())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
email = VALUES(email),
|
||||
phone = VALUES(phone),
|
||||
avatar = VALUES(avatar),
|
||||
role = VALUES(role),
|
||||
status = VALUES(status),
|
||||
updated_at = NOW();
|
||||
|
||||
-- 验证插入结果
|
||||
SELECT id, username, email, phone, status, created_at
|
||||
FROM users
|
||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 修复演示账号密码问题
|
||||
-- 使用正确的BCrypt加密密码
|
||||
|
||||
USE flash_sale_db;
|
||||
|
||||
-- 删除现有的演示用户(如果存在)
|
||||
DELETE
|
||||
FROM users
|
||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
||||
|
||||
-- 插入正确的演示用户
|
||||
-- demo1/demo2 密码: 123456
|
||||
-- admin 密码: admin123
|
||||
INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at)
|
||||
VALUES ('demo1', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo1@example.com', '13800138001', 'USER', 1,
|
||||
NOW(), NOW()),
|
||||
('demo2', '$2a$10$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'demo2@example.com', '13800138002', 'USER', 1,
|
||||
NOW(), NOW()),
|
||||
('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1,
|
||||
NOW(), NOW());
|
||||
|
||||
-- 验证插入结果
|
||||
SELECT id, username, email, phone, status, created_at
|
||||
FROM users
|
||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
||||
|
||||
-- 显示密码提示
|
||||
SELECT '演示账号密码信息:' as info;
|
||||
SELECT 'demo1 / 123456' as account_info
|
||||
UNION ALL
|
||||
SELECT 'demo2 / 123456'
|
||||
UNION ALL
|
||||
SELECT 'admin / admin123';
|
||||
@@ -1,31 +1,32 @@
|
||||
-- 秒杀系统数据库表结构
|
||||
-- 创建数据库和所有必要的表
|
||||
-- 秒杀系统数据库结构
|
||||
-- 说明:本脚本只负责数据库对象定义,不包含演示数据。
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS flash_sale_db
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS flash_sale_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE flash_sale_db;
|
||||
|
||||
-- ================================
|
||||
-- 1. 用户表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
||||
email VARCHAR(100) COMMENT '邮箱',
|
||||
phone VARCHAR(20) COMMENT '手机号',
|
||||
avatar VARCHAR(500) COMMENT '头像',
|
||||
role VARCHAR(20) DEFAULT 'USER' COMMENT '角色:ADMIN/USER',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_phone (phone),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:ADMIN/USER',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
|
||||
last_login TIMESTAMP NULL 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_users_username (username),
|
||||
INDEX idx_users_email (email),
|
||||
INDEX idx_users_phone (phone),
|
||||
INDEX idx_users_status (status),
|
||||
INDEX idx_users_created_at (created_at)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='用户表';
|
||||
@@ -33,8 +34,7 @@ CREATE TABLE IF NOT EXISTS users
|
||||
-- ================================
|
||||
-- 2. 商品表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS products
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID',
|
||||
name VARCHAR(200) NOT NULL COMMENT '商品名称',
|
||||
description TEXT COMMENT '商品描述',
|
||||
@@ -42,15 +42,15 @@ CREATE TABLE IF NOT EXISTS products
|
||||
category VARCHAR(100) COMMENT '商品分类',
|
||||
stock INT NOT NULL DEFAULT 0 COMMENT '库存数量',
|
||||
image_url VARCHAR(500) COMMENT '商品图片URL',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态:1-上架,0-下架',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
INDEX idx_name (name),
|
||||
INDEX idx_price (price),
|
||||
INDEX idx_stock (stock),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-上架,0-下架',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_products_name (name),
|
||||
INDEX idx_products_category (category),
|
||||
INDEX idx_products_price (price),
|
||||
INDEX idx_products_stock (stock),
|
||||
INDEX idx_products_status (status),
|
||||
INDEX idx_products_created_at (created_at)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='商品表';
|
||||
@@ -58,42 +58,41 @@ CREATE TABLE IF NOT EXISTS products
|
||||
-- ================================
|
||||
-- 3. 秒杀活动表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS flash_sales
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS flash_sales (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '秒杀活动ID',
|
||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||
flash_price DECIMAL(10, 2) NOT NULL COMMENT '秒杀价格',
|
||||
flash_stock INT NOT NULL COMMENT '秒杀库存',
|
||||
start_time TIMESTAMP NOT NULL COMMENT '开始时间',
|
||||
end_time TIMESTAMP NOT NULL COMMENT '结束时间',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||
INDEX idx_product_id (product_id),
|
||||
INDEX idx_start_time (start_time),
|
||||
INDEX idx_end_time (end_time),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT 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,
|
||||
INDEX idx_flash_sales_product_id (product_id),
|
||||
INDEX idx_flash_sales_start_time (start_time),
|
||||
INDEX idx_flash_sales_end_time (end_time),
|
||||
INDEX idx_flash_sales_status (status),
|
||||
INDEX idx_flash_sales_created_at (created_at)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
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',
|
||||
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',
|
||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||
quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量',
|
||||
total_price DECIMAL(10, 2) NOT NULL COMMENT '总价',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态:1-待支付,2-已支付,3-已发货,4-已完成,5-已取消',
|
||||
order_type TINYINT DEFAULT 1 COMMENT '订单类型:1-普通订单,2-秒杀订单',
|
||||
product_id BIGINT NOT NULL COMMENT '兼容字段:主商品ID',
|
||||
flash_sale_id BIGINT COMMENT '秒杀活动ID',
|
||||
group_buying_group_id BIGINT COMMENT '拼团团组ID',
|
||||
quantity INT NOT NULL DEFAULT 1 COMMENT '兼容字段:总购买数量',
|
||||
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_phone VARCHAR(20) COMMENT '收货手机号',
|
||||
receiver_address VARCHAR(255) COMMENT '收货地址',
|
||||
@@ -102,30 +101,26 @@ CREATE TABLE IF NOT EXISTS orders
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
shipped_at TIMESTAMP NULL COMMENT '发货时间',
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_product_id (product_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_order_type (order_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_user_product (user_id, product_id)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT 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,
|
||||
CONSTRAINT fk_orders_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||
INDEX idx_orders_order_no (order_no),
|
||||
INDEX idx_orders_group_no (group_no),
|
||||
INDEX idx_orders_user_id (user_id),
|
||||
INDEX idx_orders_product_id (product_id),
|
||||
INDEX idx_orders_flash_sale_id (flash_sale_id),
|
||||
INDEX idx_orders_status (status),
|
||||
INDEX idx_orders_order_type (order_type),
|
||||
INDEX idx_orders_created_at (created_at)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='订单表';
|
||||
|
||||
|
||||
|
||||
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='订单主表';
|
||||
|
||||
-- ================================
|
||||
-- 5. 订单明细表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS order_items
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '明细ID',
|
||||
order_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 '下单单价',
|
||||
quantity INT NOT NULL COMMENT '购买数量',
|
||||
subtotal DECIMAL(10, 2) NOT NULL COMMENT '小计',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
|
||||
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
CONSTRAINT fk_order_items_order 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,
|
||||
INDEX idx_order_items_order_id (order_id),
|
||||
INDEX idx_order_items_product_id (product_id)
|
||||
) ENGINE = InnoDB
|
||||
@@ -147,8 +141,7 @@ CREATE TABLE IF NOT EXISTS order_items
|
||||
-- ================================
|
||||
-- 6. 用户地址表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS user_addresses
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS user_addresses (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '地址ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
name VARCHAR(100) NOT NULL COMMENT '收货人',
|
||||
@@ -157,13 +150,12 @@ CREATE TABLE IF NOT EXISTS user_addresses
|
||||
city VARCHAR(50) COMMENT '城市',
|
||||
district VARCHAR(50) COMMENT '区县',
|
||||
address VARCHAR(255) NOT NULL COMMENT '详细地址',
|
||||
is_default TINYINT DEFAULT 0 COMMENT '是否默认地址',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
INDEX idx_address_user_id (user_id),
|
||||
INDEX idx_address_default (is_default)
|
||||
is_default TINYINT NOT NULL DEFAULT 0 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_user_addresses_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
INDEX idx_user_addresses_user_id (user_id),
|
||||
INDEX idx_user_addresses_default (is_default)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='用户地址表';
|
||||
@@ -171,8 +163,7 @@ CREATE TABLE IF NOT EXISTS user_addresses
|
||||
-- ================================
|
||||
-- 7. 商品评价表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS product_reviews
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS product_reviews (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '评价ID',
|
||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
@@ -182,50 +173,117 @@ CREATE TABLE IF NOT EXISTS product_reviews
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-显示,0-隐藏',
|
||||
admin_reply TEXT COMMENT '管理员回复',
|
||||
replied_at TIMESTAMP NULL COMMENT '回复时间',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
||||
INDEX idx_review_product_id (product_id),
|
||||
INDEX idx_review_user_id (user_id),
|
||||
UNIQUE KEY uk_review_order_user (order_id, user_id)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT 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,
|
||||
CONSTRAINT fk_product_reviews_user 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,
|
||||
UNIQUE KEY uk_review_order_user_product (order_id, user_id, product_id),
|
||||
INDEX idx_product_reviews_product_id (product_id),
|
||||
INDEX idx_product_reviews_user_id (user_id),
|
||||
INDEX idx_product_reviews_status (status)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='商品评价表';
|
||||
|
||||
|
||||
|
||||
-- ================================
|
||||
-- 8. 用户收藏表
|
||||
-- ================================
|
||||
CREATE TABLE IF NOT EXISTS user_favorites
|
||||
(
|
||||
CREATE TABLE IF NOT EXISTS user_favorites (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '收藏ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
product_id BIGINT NOT NULL COMMENT '商品ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
CONSTRAINT fk_user_favorites_user 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,
|
||||
UNIQUE KEY uk_favorite_user_product (user_id, product_id),
|
||||
INDEX idx_favorite_user_id (user_id),
|
||||
INDEX idx_favorite_product_id (product_id)
|
||||
INDEX idx_user_favorites_user_id (user_id),
|
||||
INDEX idx_user_favorites_product_id (product_id)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
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
|
||||
SELECT fs.id,
|
||||
fs.product_id,
|
||||
p.name as product_name,
|
||||
p.price as original_price,
|
||||
p.name AS product_name,
|
||||
p.price AS original_price,
|
||||
fs.flash_price,
|
||||
fs.flash_stock,
|
||||
fs.start_time,
|
||||
@@ -239,29 +297,14 @@ WHERE fs.status = 2
|
||||
AND fs.end_time > NOW()
|
||||
AND p.status = 1;
|
||||
|
||||
-- 订单统计视图
|
||||
CREATE OR REPLACE VIEW order_statistics AS
|
||||
SELECT DATE(created_at) as order_date,
|
||||
COUNT(*) as total_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 = 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(total_price) as total_amount
|
||||
SELECT DATE(created_at) AS order_date,
|
||||
COUNT(*) AS total_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 = 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(total_price) AS total_amount
|
||||
FROM orders
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY order_date DESC;
|
||||
|
||||
-- ================================
|
||||
-- 6. 显示表结构
|
||||
-- ================================
|
||||
SHOW TABLES;
|
||||
|
||||
-- 显示表结构信息
|
||||
SELECT TABLE_NAME as '表名',
|
||||
TABLE_COMMENT as '表注释',
|
||||
TABLE_ROWS as '估计行数'
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = 'flash_sale_db'
|
||||
AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY TABLE_NAME;
|
||||
|
||||
@@ -1,161 +1,126 @@
|
||||
-- 秒杀系统测试数据SQL脚本
|
||||
-- 包含演示账号、测试商品、秒杀活动等数据
|
||||
-- 测试业务数据初始化脚本
|
||||
-- 依赖:请先执行 schema.sql 和 demo-users.sql
|
||||
|
||||
-- 创建数据库(如果不存在)
|
||||
CREATE DATABASE IF NOT EXISTS flash_sale_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE flash_sale_db;
|
||||
|
||||
-- 清理现有数据(谨慎使用)
|
||||
-- DELETE FROM orders WHERE id > 0;
|
||||
-- DELETE FROM flash_sales WHERE id > 0;
|
||||
-- DELETE FROM products WHERE id > 0;
|
||||
-- DELETE FROM users WHERE id > 0;
|
||||
|
||||
-- 重置自增ID
|
||||
-- ALTER TABLE users AUTO_INCREMENT = 1;
|
||||
-- ALTER TABLE products AUTO_INCREMENT = 1;
|
||||
-- ALTER TABLE flash_sales AUTO_INCREMENT = 1;
|
||||
-- ALTER TABLE orders AUTO_INCREMENT = 1;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
DELETE FROM user_favorites;
|
||||
DELETE FROM product_reviews;
|
||||
DELETE FROM user_addresses;
|
||||
DELETE FROM order_items;
|
||||
DELETE FROM orders;
|
||||
DELETE FROM flash_sales;
|
||||
DELETE FROM products;
|
||||
DELETE FROM users WHERE username LIKE 'testuser%';
|
||||
ALTER TABLE products 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, role, status, created_at, updated_at)
|
||||
INSERT INTO users (username, password, email, phone, avatar, 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$N.zmdr9k7uOkXUJEkKWZaOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1, NOW(),
|
||||
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());
|
||||
('testuser1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test1@example.com', '13800138003', '', 'USER', 1, NOW(), NOW()),
|
||||
('testuser2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test2@example.com', '13800138004', '', 'USER', 1, NOW(), NOW()),
|
||||
('testuser3', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'test3@example.com', '13800138005', '', 'USER', 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
email = VALUES(email),
|
||||
phone = VALUES(phone),
|
||||
updated_at = NOW();
|
||||
|
||||
-- ================================
|
||||
-- 2. 插入测试商品数据
|
||||
-- 2. 商品
|
||||
-- ================================
|
||||
|
||||
INSERT INTO products (name, description, price, category, stock, image_url, status, created_at, updated_at)
|
||||
VALUES
|
||||
-- 电子产品类
|
||||
('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.jpg', 1, NOW(), NOW()),
|
||||
('iPad Air', '10.9英寸液晶显示屏,M1芯片', 4399.00, '电子产品', 80, '/images/ipad.jpg', 1, NOW(), NOW()),
|
||||
('AirPods Pro 2', '主动降噪无线耳机,空间音频', 1899.00, '电子产品', 200, '/images/airpods.jpg', 1, NOW(), NOW()),
|
||||
('Apple Watch Series 9', '健康监测,GPS+蜂窝网络', 3199.00, '电子产品', 150, '/images/watch.jpg', 1, NOW(), NOW()),
|
||||
|
||||
-- 家电类
|
||||
('小米电视 65英寸', '4K超高清,120Hz刷新率', 2999.00, '家电', 60, '/images/tv.jpg', 1, NOW(), NOW()),
|
||||
('戴森吸尘器 V15', '激光显微尘,强劲吸力', 4690.00, '家电', 40, '/images/dyson.jpg', 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());
|
||||
('iPhone 15 Pro Max', '苹果最新旗舰手机,A17 Pro 芯片,钛金属设计。', 9999.00, '电子产品', 100, '/images/iphone15.svg', 1, NOW(), NOW()),
|
||||
('MacBook Pro 16英寸', 'M3 Max 芯片,36GB 内存,1TB 存储。', 25999.00, '电子产品', 50, '/images/macbook.svg', 1, NOW(), NOW()),
|
||||
('iPad Air', '10.9 英寸显示屏,轻薄便携。', 4399.00, '电子产品', 80, '/images/ipad.svg', 1, NOW(), NOW()),
|
||||
('AirPods Pro 2', '主动降噪无线耳机。', 1899.00, '电子产品', 200, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||
('Apple Watch Series 9', '健康监测与运动记录。', 3199.00, '电子产品', 150, '/images/default-product.svg', 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()),
|
||||
('深入理解Java虚拟机', 'JVM 原理与实践,第 3 版。', 89.00, '图书音像', 500, '/images/default-product.svg', 1, NOW(), NOW()),
|
||||
('五常大米 10kg', '东北优质大米,香甜可口。', 168.00, '食品饮料', 200, '/images/default-product.svg', 1, NOW(), NOW());
|
||||
|
||||
-- ================================
|
||||
-- 3. 插入秒杀活动数据
|
||||
-- 3. 秒杀活动
|
||||
-- ================================
|
||||
|
||||
INSERT INTO flash_sales (product_id, flash_price, flash_stock, start_time, end_time, status, created_at, updated_at)
|
||||
VALUES
|
||||
-- 正在进行的秒杀活动
|
||||
(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()),
|
||||
(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()),
|
||||
(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());
|
||||
(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());
|
||||
|
||||
-- ================================
|
||||
-- 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
|
||||
-- demo1用户的订单
|
||||
(1, 11, 1, 89.00, 4, 1, DATE_SUB(NOW(), INTERVAL 2 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)),
|
||||
|
||||
-- demo2用户的订单
|
||||
(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));
|
||||
('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)),
|
||||
('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)),
|
||||
('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)),
|
||||
('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));
|
||||
|
||||
-- ================================
|
||||
-- 5. 查询验证数据
|
||||
-- 6. 订单明细
|
||||
-- ================================
|
||||
|
||||
-- 查看用户数据
|
||||
SELECT 'Users:' as table_name;
|
||||
SELECT id, username, email, phone, status, created_at
|
||||
FROM users
|
||||
ORDER BY id;
|
||||
|
||||
-- 查看商品数据
|
||||
SELECT 'Products:' as table_name;
|
||||
SELECT id, name, price, stock, status
|
||||
FROM products
|
||||
ORDER BY id
|
||||
LIMIT 10;
|
||||
|
||||
-- 查看秒杀活动数据
|
||||
SELECT 'Flash Sales:' as table_name;
|
||||
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;
|
||||
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'
|
||||
UNION ALL
|
||||
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'
|
||||
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 = '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'
|
||||
UNION ALL
|
||||
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'
|
||||
UNION ALL
|
||||
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'
|
||||
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 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';
|
||||
|
||||
-- ================================
|
||||
-- 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,
|
||||
(SELECT COUNT(*) FROM products) as total_products,
|
||||
(SELECT COUNT(*) FROM flash_sales) as total_flash_sales,
|
||||
(SELECT COUNT(*) FROM orders) as total_orders,
|
||||
(SELECT COUNT(*) FROM flash_sales WHERE status = 2) as active_flash_sales,
|
||||
(SELECT COUNT(*) FROM orders WHERE status = 1) as pending_orders;
|
||||
-- ================================
|
||||
-- 8. 收藏
|
||||
-- ================================
|
||||
INSERT INTO user_favorites (user_id, product_id, created_at)
|
||||
VALUES
|
||||
((SELECT id FROM users WHERE username = 'demo1'), 1, NOW()),
|
||||
((SELECT id FROM users WHERE username = 'demo1'), 4, NOW()),
|
||||
((SELECT id FROM users WHERE username = 'demo2'), 2, NOW()),
|
||||
((SELECT id FROM users WHERE username = 'testuser1'), 9, NOW());
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
-- 更新演示账号密码为BCrypt格式
|
||||
-- 这些是使用BCryptPasswordEncoder生成的正确哈希值
|
||||
|
||||
USE flash_sale_db;
|
||||
|
||||
-- 删除现有演示用户(如果存在)
|
||||
DELETE
|
||||
FROM users
|
||||
WHERE username IN ('demo1', 'demo2', 'admin');
|
||||
|
||||
-- 插入使用BCrypt加密的演示用户
|
||||
-- demo1/demo2 密码: 123456 (BCrypt哈希)
|
||||
-- admin 密码: admin123 (BCrypt哈希)
|
||||
INSERT INTO users (username, password, email, phone, role, status, created_at, updated_at)
|
||||
VALUES ('demo1', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo1@example.com', '13800138001', 'USER', 1,
|
||||
NOW(), NOW()),
|
||||
('demo2', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'demo2@example.com', '13800138002', 'USER', 1,
|
||||
NOW(), NOW()),
|
||||
('admin', '$2a$10$DOwVJZHH.5PkZKJKJKJKJOh.3VQ8nl83hq8/Qhx6.5PkZKJKJKJKJ', 'admin@example.com', '13800138000', 'ADMIN', 1,
|
||||
NOW(), NOW());
|
||||
|
||||
-- 验证插入结果
|
||||
SELECT id,
|
||||
username,
|
||||
email,
|
||||
phone,
|
||||
status,
|
||||
SUBSTRING(password, 1, 30) as password_hash_preview,
|
||||
created_at
|
||||
FROM users
|
||||
WHERE username IN ('demo1', 'demo2', 'admin')
|
||||
ORDER BY username;
|
||||
|
||||
-- 显示账号信息
|
||||
SELECT '=== 演示账号信息 ===' as info;
|
||||
SELECT CONCAT(username, ' / ', CASE
|
||||
WHEN username = 'admin' THEN 'admin123'
|
||||
ELSE '123456'
|
||||
END) as '用户名/密码'
|
||||
FROM users
|
||||
WHERE username IN ('demo1', 'demo2', 'admin')
|
||||
ORDER BY username;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,464 +0,0 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %>
|
||||
|
||||
<c:set var="pageTitle" value="管理后台"/>
|
||||
<%@ include file="../common/header.jsp" %>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- 侧边栏 -->
|
||||
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
|
||||
<div class="position-sticky pt-3">
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
||||
<span>管理功能</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="${pageContext.request.contextPath}/admin">
|
||||
<i class="fas fa-tachometer-alt"></i> 仪表盘
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/products">
|
||||
<i class="fas fa-box"></i> 商品管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/flashsales">
|
||||
<i class="fas fa-bolt"></i> 秒杀管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/orders">
|
||||
<i class="fas fa-shopping-cart"></i> 订单管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/users">
|
||||
<i class="fas fa-users"></i> 用户管理
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">管理后台仪表盘</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshData()">
|
||||
<i class="fas fa-sync-alt"></i> 刷新数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
总用户数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="totalUsers">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-users fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
总商品数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="totalProducts">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-box fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
活跃秒杀
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="activeFlashSales">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-bolt fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
今日订单
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800" id="todayOrders">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-shopping-cart fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-rocket"></i> 快速操作</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="${pageContext.request.contextPath}/admin/products"
|
||||
class="btn btn-primary btn-block">
|
||||
<i class="fas fa-plus"></i> 添加商品
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="${pageContext.request.contextPath}/admin/flashsales"
|
||||
class="btn btn-success btn-block">
|
||||
<i class="fas fa-bolt"></i> 创建秒杀
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="${pageContext.request.contextPath}/admin/orders"
|
||||
class="btn btn-info btn-block">
|
||||
<i class="fas fa-list"></i> 查看订单
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="${pageContext.request.contextPath}/admin/monitor"
|
||||
class="btn btn-warning btn-block">
|
||||
<i class="fas fa-chart-line"></i> 系统监控
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近活动 -->
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-clock"></i> 最近订单</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>用户</th>
|
||||
<th>商品</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentOrders">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-fire"></i> 热门商品</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="hotProducts">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
padding: 48px 0 0;
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-left: 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
function loadDashboardData() {
|
||||
// 加载统计数据
|
||||
loadStatistics();
|
||||
|
||||
// 加载最近订单
|
||||
loadRecentOrders();
|
||||
|
||||
// 加载热门商品
|
||||
loadHotProducts();
|
||||
}
|
||||
|
||||
function loadStatistics() {
|
||||
// 调用真实API获取统计数据
|
||||
$.get('${pageContext.request.contextPath}/api/admin/dashboard/stats')
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
updateDashboardStats(response.data);
|
||||
} else {
|
||||
console.error('获取仪表盘数据失败:', response.message);
|
||||
// 显示默认值
|
||||
updateDashboardStats({});
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
console.error('获取仪表盘数据请求失败');
|
||||
// 显示默认值
|
||||
updateDashboardStats({});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新仪表盘统计数据
|
||||
function updateDashboardStats(stats) {
|
||||
$('#totalUsers').text(formatNumber(stats.totalUsers || 0));
|
||||
$('#totalProducts').text(formatNumber(stats.totalProducts || 0));
|
||||
$('#activeFlashSales').text(formatNumber(stats.activeFlashSales || 0));
|
||||
$('#todayOrders').text(formatNumber(stats.todayOrders || 0));
|
||||
|
||||
// 更新订单统计卡片
|
||||
$('#totalOrders').text(formatNumber(stats.totalOrders || 0));
|
||||
$('#paidOrders').text(formatNumber(stats.paidOrders || 0));
|
||||
$('#pendingOrders').text(formatNumber(stats.pendingOrders || 0));
|
||||
$('#totalAmount').text('¥' + formatNumber(stats.totalAmount || 0));
|
||||
}
|
||||
|
||||
function loadRecentOrders() {
|
||||
// 调用真实API获取最近订单
|
||||
$.get('${pageContext.request.contextPath}/api/admin/orders/recent?limit=10')
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
updateRecentOrders(response.data);
|
||||
} else {
|
||||
console.error('获取最近订单失败:', response.message);
|
||||
$('#recentOrders').html('<tr><td colspan="6" class="text-center">获取订单数据失败</td></tr>');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
console.error('获取最近订单请求失败');
|
||||
$('#recentOrders').html('<tr><td colspan="6" class="text-center">网络请求失败</td></tr>');
|
||||
});
|
||||
}
|
||||
|
||||
// 更新最近订单列表
|
||||
function updateRecentOrders(orders) {
|
||||
let html = '';
|
||||
if (orders && orders.length > 0) {
|
||||
orders.forEach(function (order) {
|
||||
let statusClass = getOrderStatusClass(order.status);
|
||||
let statusText = getOrderStatusText(order.status);
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>` + order.id + `</td>
|
||||
<td>` + order.username + `</td>
|
||||
<td>` + order.productName + `</td>
|
||||
<td>¥` + formatNumber(order.totalAmount) + `</td>
|
||||
<td><span class="badge ` + statusClass + `">` + statusText + `</span></td>
|
||||
<td>` + formatDateTime(order.createdAt) + `</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
html = '<tr><td colspan="6" class="text-center">暂无订单数据</td></tr>';
|
||||
}
|
||||
$('#recentOrders').html(html);
|
||||
}
|
||||
|
||||
function loadHotProducts() {
|
||||
// 模拟数据,实际应该调用API
|
||||
setTimeout(function () {
|
||||
const products = [
|
||||
{name: 'iPhone 15 Pro Max', sales: 156},
|
||||
{name: 'MacBook Pro 16英寸', sales: 89},
|
||||
{name: 'iPad Air', sales: 67},
|
||||
{name: 'AirPods Pro 2', sales: 234}
|
||||
];
|
||||
|
||||
let html = '';
|
||||
products.forEach((product, index) => {
|
||||
html += `
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<small class="text-muted">#${index + 1}</small>
|
||||
<span class="ms-2">${product.name}</span>
|
||||
</div>
|
||||
<span class="badge bg-success">${product.sales}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
$('#hotProducts').html(html);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
// 显示加载状态
|
||||
$('#totalUsers, #totalProducts, #activeFlashSales, #todayOrders').html('<i class="fas fa-spinner fa-spin"></i>');
|
||||
$('#recentOrders').html('<tr><td colspan="6" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
||||
$('#hotProducts').html('<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>');
|
||||
|
||||
// 重新加载数据
|
||||
loadDashboardData();
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatDateTime(dateTime) {
|
||||
if (!dateTime) return '';
|
||||
return new Date(dateTime).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function getOrderStatusClass(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'bg-warning'; // 待支付
|
||||
case 2:
|
||||
return 'bg-success'; // 已支付
|
||||
case 3:
|
||||
return 'bg-info'; // 已发货
|
||||
case 4:
|
||||
return 'bg-primary'; // 已完成
|
||||
case 5:
|
||||
return 'bg-danger'; // 已取消
|
||||
default:
|
||||
return 'bg-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusText(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '待支付';
|
||||
case 2:
|
||||
return '已支付';
|
||||
case 3:
|
||||
return '已发货';
|
||||
case 4:
|
||||
return '已完成';
|
||||
case 5:
|
||||
return '已取消';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<%@ include file="../common/footer.jsp" %>
|
||||
@@ -1,515 +0,0 @@
|
||||
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统监控 - 管理后台</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
.monitor-card {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.metric-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.metric-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.status-online { background-color: #28a745; }
|
||||
.status-warning { background-color: #ffc107; }
|
||||
.status-offline { background-color: #dc3545; }
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
.log-container {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.log-line {
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.log-error { color: #dc3545; }
|
||||
.log-warn { color: #ffc107; }
|
||||
.log-info { color: #17a2b8; }
|
||||
.log-debug { color: #6c757d; }
|
||||
.refresh-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/admin">
|
||||
<i class="fas fa-tachometer-alt me-2"></i>管理后台
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a class="nav-link" href="/admin">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>
|
||||
<i class="fas fa-chart-line me-2"></i>系统监控
|
||||
<button class="btn btn-outline-primary btn-sm ms-3 refresh-btn" onclick="refreshAll()">
|
||||
<i class="fas fa-sync-alt"></i> 刷新
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统状态卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card monitor-card bg-primary text-white">
|
||||
<div class="card-body metric-item">
|
||||
<div class="metric-value" id="cpu-usage">0%</div>
|
||||
<div class="metric-label">CPU 使用率</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card monitor-card bg-info text-white">
|
||||
<div class="card-body metric-item">
|
||||
<div class="metric-value" id="memory-usage">0%</div>
|
||||
<div class="metric-label">内存使用率</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card monitor-card bg-success text-white">
|
||||
<div class="card-body metric-item">
|
||||
<div class="metric-value" id="active-users">0</div>
|
||||
<div class="metric-label">在线用户</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card monitor-card bg-warning text-white">
|
||||
<div class="card-body metric-item">
|
||||
<div class="metric-value" id="total-requests">0</div>
|
||||
<div class="metric-label">今日请求</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务状态 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card monitor-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server me-2"></i>服务状态
|
||||
</h5>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="checkServices()">
|
||||
<i class="fas fa-check"></i> 检查
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush" id="service-status">
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="status-indicator status-online"></span>
|
||||
应用服务
|
||||
</div>
|
||||
<span class="badge bg-success rounded-pill">运行中</span>
|
||||
</div>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="status-indicator" id="redis-indicator"></span>
|
||||
Redis 服务
|
||||
</div>
|
||||
<span class="badge rounded-pill" id="redis-status">检查中...</span>
|
||||
</div>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="status-indicator" id="mysql-indicator"></span>
|
||||
MySQL 服务
|
||||
</div>
|
||||
<span class="badge rounded-pill" id="mysql-status">检查中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card monitor-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-area me-2"></i>系统性能趋势
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="performance-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 业务监控 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card monitor-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-bolt me-2"></i>秒杀活动监控
|
||||
</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="loadFlashSaleStats()">
|
||||
<i class="fas fa-sync-alt"></i> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="flashsale-monitor">
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
||||
<p class="mt-3 text-muted">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card monitor-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>系统告警
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="system-alerts">
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
系统运行正常
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时日志 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="card monitor-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-file-alt me-2"></i>实时日志
|
||||
</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" onclick="clearLogs()">
|
||||
<i class="fas fa-trash"></i> 清空
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" onclick="toggleAutoRefresh()">
|
||||
<i class="fas fa-play" id="auto-refresh-icon"></i>
|
||||
<span id="auto-refresh-text">自动刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="log-container" id="log-container">
|
||||
<div class="log-line log-info">[INFO] 系统监控页面已加载</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script>
|
||||
let performanceChart;
|
||||
let autoRefreshInterval;
|
||||
let isAutoRefreshEnabled = false;
|
||||
|
||||
$(document).ready(function() {
|
||||
initPerformanceChart();
|
||||
loadSystemMetrics();
|
||||
checkServices();
|
||||
loadFlashSaleStats();
|
||||
loadSystemLogs();
|
||||
});
|
||||
|
||||
function initPerformanceChart() {
|
||||
const ctx = document.getElementById('performance-chart').getContext('2d');
|
||||
performanceChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'CPU使用率',
|
||||
data: [],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.1
|
||||
}, {
|
||||
label: '内存使用率',
|
||||
data: [],
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadSystemMetrics() {
|
||||
$.get('/api/admin/system/metrics', function(data) {
|
||||
$('#cpu-usage').text(data.cpuUsage + '%');
|
||||
$('#memory-usage').text(data.memoryUsage + '%');
|
||||
$('#active-users').text(data.activeUsers);
|
||||
$('#total-requests').text(data.totalRequests.toLocaleString());
|
||||
|
||||
// 更新图表
|
||||
updatePerformanceChart(data.cpuUsage, data.memoryUsage);
|
||||
|
||||
addLogEntry('info', '系统指标已更新');
|
||||
}).fail(function() {
|
||||
// 模拟数据
|
||||
const mockData = {
|
||||
cpuUsage: Math.floor(Math.random() * 80) + 10,
|
||||
memoryUsage: Math.floor(Math.random() * 70) + 20,
|
||||
activeUsers: Math.floor(Math.random() * 100) + 50,
|
||||
totalRequests: Math.floor(Math.random() * 10000) + 5000
|
||||
};
|
||||
|
||||
$('#cpu-usage').text(mockData.cpuUsage + '%');
|
||||
$('#memory-usage').text(mockData.memoryUsage + '%');
|
||||
$('#active-users').text(mockData.activeUsers);
|
||||
$('#total-requests').text(mockData.totalRequests.toLocaleString());
|
||||
|
||||
updatePerformanceChart(mockData.cpuUsage, mockData.memoryUsage);
|
||||
|
||||
addLogEntry('warn', '无法连接到监控API,显示模拟数据');
|
||||
});
|
||||
}
|
||||
|
||||
function updatePerformanceChart(cpuUsage, memoryUsage) {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
|
||||
performanceChart.data.labels.push(now);
|
||||
performanceChart.data.datasets[0].data.push(cpuUsage);
|
||||
performanceChart.data.datasets[1].data.push(memoryUsage);
|
||||
|
||||
// 保持最近20个数据点
|
||||
if (performanceChart.data.labels.length > 20) {
|
||||
performanceChart.data.labels.shift();
|
||||
performanceChart.data.datasets[0].data.shift();
|
||||
performanceChart.data.datasets[1].data.shift();
|
||||
}
|
||||
|
||||
performanceChart.update();
|
||||
}
|
||||
|
||||
function checkServices() {
|
||||
// 检查Redis服务
|
||||
$.get('/api/admin/health/redis').done(function() {
|
||||
updateServiceStatus('redis', true);
|
||||
}).fail(function() {
|
||||
updateServiceStatus('redis', false);
|
||||
});
|
||||
|
||||
// 检查MySQL服务
|
||||
$.get('/api/admin/health/mysql').done(function() {
|
||||
updateServiceStatus('mysql', true);
|
||||
}).fail(function() {
|
||||
updateServiceStatus('mysql', false);
|
||||
});
|
||||
}
|
||||
|
||||
function updateServiceStatus(service, isOnline) {
|
||||
const indicator = $('#' + service + '-indicator');
|
||||
const status = $('#' + service + '-status');
|
||||
|
||||
if (isOnline) {
|
||||
indicator.removeClass('status-warning status-offline').addClass('status-online');
|
||||
status.removeClass('bg-warning bg-danger').addClass('bg-success').text('运行中');
|
||||
addLogEntry('info', service.toUpperCase() + ' 服务状态正常');
|
||||
} else {
|
||||
indicator.removeClass('status-online status-warning').addClass('status-offline');
|
||||
status.removeClass('bg-success bg-warning').addClass('bg-danger').text('离线');
|
||||
addLogEntry('error', service.toUpperCase() + ' 服务连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
function loadFlashSaleStats() {
|
||||
$.get('/api/admin/flashsale/monitor', function(data) {
|
||||
let html = '<div class="row">';
|
||||
|
||||
if (data && data.length > 0) {
|
||||
data.forEach(function(flashSale) {
|
||||
const progressPercentage = Math.max(0, (flashSale.flashStock - flashSale.remainingStock) / flashSale.flashStock * 100);
|
||||
|
||||
html += `
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">${flashSale.productName}</h6>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small>已售:${flashSale.flashStock - flashSale.remainingStock}</small>
|
||||
<small>剩余:${flashSale.remainingStock}</small>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar ${progressPercentage > 80 ? 'bg-warning' : 'bg-success'}"
|
||||
style="width: ${progressPercentage}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-1 d-block">状态: ${flashSale.statusDescription}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
html += '<div class="col-12"><p class="text-muted text-center">暂无活跃的秒杀活动</p></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
$('#flashsale-monitor').html(html);
|
||||
|
||||
addLogEntry('info', '秒杀活动监控数据已更新');
|
||||
}).fail(function() {
|
||||
$('#flashsale-monitor').html('<div class="text-center py-4"><p class="text-danger">加载失败</p></div>');
|
||||
addLogEntry('error', '加载秒杀监控数据失败');
|
||||
});
|
||||
}
|
||||
|
||||
function loadSystemLogs() {
|
||||
// 模拟实时日志
|
||||
const logTypes = ['info', 'warn', 'error', 'debug'];
|
||||
const logMessages = [
|
||||
'用户登录成功',
|
||||
'秒杀活动开始',
|
||||
'Redis连接池满',
|
||||
'数据库查询耗时较长',
|
||||
'缓存命中率下降',
|
||||
'用户注册完成',
|
||||
'订单支付成功',
|
||||
'库存更新完成'
|
||||
];
|
||||
|
||||
// 添加一些初始日志
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const type = logTypes[Math.floor(Math.random() * logTypes.length)];
|
||||
const message = logMessages[Math.floor(Math.random() * logMessages.length)];
|
||||
addLogEntry(type, message);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function addLogEntry(level, message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logLine = `<div class="log-line log-${level}">[${timestamp}] [${level.toUpperCase()}] ${message}</div>`;
|
||||
|
||||
$('#log-container').prepend(logLine);
|
||||
|
||||
// 保持最多100条日志
|
||||
const logLines = $('#log-container .log-line');
|
||||
if (logLines.length > 100) {
|
||||
logLines.slice(100).remove();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadSystemMetrics();
|
||||
checkServices();
|
||||
loadFlashSaleStats();
|
||||
addLogEntry('info', '手动刷新所有监控数据');
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
$('#log-container').empty();
|
||||
addLogEntry('info', '日志已清空');
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
if (isAutoRefreshEnabled) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
$('#auto-refresh-icon').removeClass('fa-stop').addClass('fa-play');
|
||||
$('#auto-refresh-text').text('自动刷新');
|
||||
isAutoRefreshEnabled = false;
|
||||
addLogEntry('info', '自动刷新已停止');
|
||||
} else {
|
||||
autoRefreshInterval = setInterval(function() {
|
||||
loadSystemMetrics();
|
||||
checkServices();
|
||||
loadFlashSaleStats();
|
||||
}, 10000); // 每10秒刷新
|
||||
|
||||
$('#auto-refresh-icon').removeClass('fa-play').addClass('fa-stop');
|
||||
$('#auto-refresh-text').text('停止刷新');
|
||||
isAutoRefreshEnabled = true;
|
||||
addLogEntry('info', '自动刷新已启动');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面离开时清理定时器
|
||||
$(window).on('beforeunload', function() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,545 +0,0 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %>
|
||||
|
||||
<c:set var="pageTitle" value="订单管理"/>
|
||||
<%@ include file="../common/header.jsp" %>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- 侧边栏 -->
|
||||
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
|
||||
<div class="position-sticky pt-3">
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
||||
<span>管理功能</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin">
|
||||
<i class="fas fa-tachometer-alt"></i> 仪表盘
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/products">
|
||||
<i class="fas fa-box"></i> 商品管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/flashsales">
|
||||
<i class="fas fa-bolt"></i> 秒杀管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="${pageContext.request.contextPath}/admin/orders">
|
||||
<i class="fas fa-shopping-cart"></i> 订单管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/users">
|
||||
<i class="fas fa-users"></i> 用户管理
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">订单管理</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshOrders()">
|
||||
<i class="fas fa-sync-alt"></i> 刷新
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="exportOrders()">
|
||||
<i class="fas fa-download"></i> 导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="搜索订单号/用户...">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="searchOrders()">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="statusFilter" onchange="filterOrders()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待支付</option>
|
||||
<option value="paid">已支付</option>
|
||||
<option value="shipped">已发货</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" class="form-control" id="dateFilter" onchange="filterOrders()">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="sortBy" onchange="sortOrders()">
|
||||
<option value="created_at">按创建时间</option>
|
||||
<option value="total_amount">按订单金额</option>
|
||||
<option value="status">按订单状态</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单统计 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-primary" id="totalOrders">0</h5>
|
||||
<p class="card-text">总订单数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success" id="paidOrders">0</h5>
|
||||
<p class="card-text">已支付订单</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-warning" id="pendingOrders">0</h5>
|
||||
<p class="card-text">待处理订单</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-info" id="totalAmount">¥0</h5>
|
||||
<p class="card-text">总交易额</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>用户</th>
|
||||
<th>商品信息</th>
|
||||
<th>数量</th>
|
||||
<th>总金额</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ordersTableBody">
|
||||
<tr>
|
||||
<td colspan="8" class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<nav aria-label="订单分页">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- 分页按钮将通过JavaScript生成 -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情模态框 -->
|
||||
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">订单详情</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="orderDetailContent">
|
||||
<!-- 订单详情内容 -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
padding: 48px 0 0;
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-left: 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let pageSize = 10;
|
||||
let totalPages = 1;
|
||||
|
||||
$(document).ready(function () {
|
||||
loadOrders();
|
||||
loadOrderStats();
|
||||
});
|
||||
|
||||
function loadOrders(page = 1) {
|
||||
currentPage = page;
|
||||
|
||||
// 显示加载状态
|
||||
$('#orderTableBody').html('<tr><td colspan="8" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
||||
|
||||
// 构建请求参数
|
||||
let params = {
|
||||
page: page,
|
||||
size: 10
|
||||
};
|
||||
|
||||
const keyword = $('#searchKeyword').val();
|
||||
const status = $('#statusFilter').val();
|
||||
|
||||
if (keyword && keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
if (status && status !== '') {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
// 调用真实API
|
||||
$.get('${pageContext.request.contextPath}/api/admin/orders', params)
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
renderOrdersTable(response.data.orders);
|
||||
renderPagination(response.data.currentPage, response.data.totalPages);
|
||||
} else {
|
||||
$('#orderTableBody').html('<tr><td colspan="8" class="text-center text-danger">获取订单数据失败: ' + response.message + '</td></tr>');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
$('#orderTableBody').html('<tr><td colspan="8" class="text-center text-danger">网络请求失败,请稍后重试</td></tr>');
|
||||
});
|
||||
}
|
||||
|
||||
function loadOrderStats() {
|
||||
// 调用真实API获取订单统计数据
|
||||
$.get('${pageContext.request.contextPath}/api/admin/orders/stats')
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
updateOrderStats(response.data);
|
||||
} else {
|
||||
console.error('获取订单统计数据失败:', response.message);
|
||||
// 显示默认值
|
||||
updateOrderStats({});
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
console.error('获取订单统计数据请求失败');
|
||||
// 显示默认值
|
||||
updateOrderStats({});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新订单统计数据
|
||||
function updateOrderStats(stats) {
|
||||
$('#totalOrders').text(formatNumber(stats.totalOrders || 0));
|
||||
$('#paidOrders').text(formatNumber(stats.paidOrders || 0));
|
||||
$('#pendingOrders').text(formatNumber(stats.pendingOrders || 0));
|
||||
$('#totalAmount').text('¥' + formatNumber(stats.totalAmount || 0));
|
||||
}
|
||||
|
||||
function renderOrdersTable(orders) {
|
||||
let html = '';
|
||||
|
||||
if (orders.length === 0) {
|
||||
html = '<tr><td colspan="8" class="text-center">暂无订单数据</td></tr>';
|
||||
} else {
|
||||
orders.forEach(order => {
|
||||
const statusText = getOrderStatusText(order.status);
|
||||
const statusClass = getOrderStatusClass(order.status);
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold">` + order.id + `</div>
|
||||
` + (order.isFlashSale ? '<small class="text-danger"><i class="fas fa-bolt"></i> 秒杀订单</small>' : '') + `
|
||||
</td>
|
||||
<td>` + order.username + `</td>
|
||||
<td>` + order.productName + `</td>
|
||||
<td>` + order.quantity + `</td>
|
||||
<td class="fw-bold">¥` + formatNumber(order.totalAmount || 0) + `</td>
|
||||
<td>
|
||||
<span class="badge ` + statusClass + `">
|
||||
` + statusText + `
|
||||
</span>
|
||||
</td>
|
||||
<td>` + formatDateTime(order.createdAt) + `</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="viewOrderDetail('` + order.id + `')" title="查看详情">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
` + (order.status === 2 ?
|
||||
'<button class="btn btn-outline-success" onclick="shipOrder(\'' + order.id + '\')" title="发货"><i class="fas fa-shipping-fast"></i></button>' : '') + `
|
||||
` + (order.status === 1 ?
|
||||
'<button class="btn btn-outline-danger" onclick="cancelOrder(\'' + order.id + '\')" title="取消"><i class="fas fa-times"></i></button>' : '') + `
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
$('#ordersTableBody').html(html);
|
||||
}
|
||||
|
||||
function getOrderStatusText(status) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '待支付';
|
||||
case 'paid':
|
||||
return '已支付';
|
||||
case 'shipped':
|
||||
return '已发货';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusClass(status) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-warning';
|
||||
case 'paid':
|
||||
return 'bg-success';
|
||||
case 'shipped':
|
||||
return 'bg-info';
|
||||
case 'completed':
|
||||
return 'bg-primary';
|
||||
case 'cancelled':
|
||||
return 'bg-secondary';
|
||||
default:
|
||||
return 'bg-light';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination(total, pageSize) {
|
||||
totalPages = Math.ceil(total / pageSize);
|
||||
let html = '';
|
||||
|
||||
// 上一页
|
||||
html += `
|
||||
<li class="page-item ` + (currentPage === 1 ? 'disabled' : '') + `">
|
||||
<a class="page-link" href="#" onclick="loadOrders(` + (currentPage - 1) + `)">上一页</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// 页码
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
html += `
|
||||
<li class="page-item ` + (i === currentPage ? 'active' : '') + `">
|
||||
<a class="page-link" href="#" onclick="loadOrders(` + i + `)">` + i + `</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
// 下一页
|
||||
html += `
|
||||
<li class="page-item ` + (currentPage === totalPages ? 'disabled' : '') + `">
|
||||
<a class="page-link" href="#" onclick="loadOrders(` + (currentPage + 1) + `)">下一页</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
$('#pagination').html(html);
|
||||
}
|
||||
|
||||
function refreshOrders() {
|
||||
$('#ordersTableBody').html('<tr><td colspan="8" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
||||
loadOrders(currentPage);
|
||||
loadOrderStats();
|
||||
}
|
||||
|
||||
function searchOrders() {
|
||||
const keyword = $('#searchInput').val();
|
||||
console.log('搜索订单:', keyword);
|
||||
loadOrders(1);
|
||||
}
|
||||
|
||||
function filterOrders() {
|
||||
const status = $('#statusFilter').val();
|
||||
const date = $('#dateFilter').val();
|
||||
console.log('筛选订单:', {status, date});
|
||||
loadOrders(1);
|
||||
}
|
||||
|
||||
function sortOrders() {
|
||||
const sortBy = $('#sortBy').val();
|
||||
console.log('排序方式:', sortBy);
|
||||
loadOrders(1);
|
||||
}
|
||||
|
||||
function viewOrderDetail(orderId) {
|
||||
console.log('查看订单详情:', orderId);
|
||||
|
||||
// 模拟获取订单详情
|
||||
const orderDetail = `
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>订单信息</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><td>订单号:</td><td>` + orderId + `</td></tr>
|
||||
<tr><td>用户:</td><td>demo1</td></tr>
|
||||
<tr><td>状态:</td><td><span class="badge bg-success">已支付</span></td></tr>
|
||||
<tr><td>创建时间:</td><td>2025-06-29 10:30:15</td></tr>
|
||||
<tr><td>支付时间:</td><td>2025-06-29 10:31:20</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>商品信息</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><td>商品名称:</td><td>iPhone 15 Pro Max</td></tr>
|
||||
<tr><td>单价:</td><td>¥8,888.00</td></tr>
|
||||
<tr><td>数量:</td><td>1</td></tr>
|
||||
<tr><td>总金额:</td><td class="fw-bold">¥8,888.00</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6>收货地址</h6>
|
||||
<p>北京市朝阳区xxx街道xxx号xxx小区xxx楼xxx室<br>
|
||||
收货人: 张三 13800138001</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$('#orderDetailContent').html(orderDetail);
|
||||
$('#orderDetailModal').modal('show');
|
||||
}
|
||||
|
||||
function shipOrder(orderId) {
|
||||
if (confirm('确定要将此订单标记为已发货吗?')) {
|
||||
console.log('发货订单:', orderId);
|
||||
|
||||
setTimeout(function () {
|
||||
alert('订单已标记为已发货!');
|
||||
refreshOrders();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOrder(orderId) {
|
||||
if (confirm('确定要取消此订单吗?此操作不可恢复。')) {
|
||||
console.log('取消订单:', orderId);
|
||||
|
||||
setTimeout(function () {
|
||||
alert('订单已取消!');
|
||||
refreshOrders();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function exportOrders() {
|
||||
console.log('导出订单数据');
|
||||
alert('订单数据导出功能开发中...');
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatDateTime(dateTime) {
|
||||
if (!dateTime) return '';
|
||||
return new Date(dateTime).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function getOrderStatusClass(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'bg-warning'; // 待支付
|
||||
case 2:
|
||||
return 'bg-success'; // 已支付
|
||||
case 3:
|
||||
return 'bg-info'; // 已发货
|
||||
case 4:
|
||||
return 'bg-primary'; // 已完成
|
||||
case 5:
|
||||
return 'bg-danger'; // 已取消
|
||||
default:
|
||||
return 'bg-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusText(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '待支付';
|
||||
case 2:
|
||||
return '已支付';
|
||||
case 3:
|
||||
return '已发货';
|
||||
case 4:
|
||||
return '已完成';
|
||||
case 5:
|
||||
return '已取消';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<%@ include file="../common/footer.jsp" %>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,404 +0,0 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://flashsale.org/functions" %>
|
||||
|
||||
<c:set var="pageTitle" value="用户管理"/>
|
||||
<%@ include file="../common/header.jsp" %>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- 侧边栏 -->
|
||||
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
|
||||
<div class="position-sticky pt-3">
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
||||
<span>管理功能</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin">
|
||||
<i class="fas fa-tachometer-alt"></i> 仪表盘
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/products">
|
||||
<i class="fas fa-box"></i> 商品管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/flashsales">
|
||||
<i class="fas fa-bolt"></i> 秒杀管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="${pageContext.request.contextPath}/admin/orders">
|
||||
<i class="fas fa-shopping-cart"></i> 订单管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="${pageContext.request.contextPath}/admin/users">
|
||||
<i class="fas fa-users"></i> 用户管理
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">用户管理</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshUsers()">
|
||||
<i class="fas fa-sync-alt"></i> 刷新
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="exportUsers()">
|
||||
<i class="fas fa-download"></i> 导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="搜索用户名/邮箱/手机...">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="searchUsers()">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="statusFilter" onchange="filterUsers()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="1">正常</option>
|
||||
<option value="0">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" class="form-control" id="dateFilter" onchange="filterUsers()">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="sortBy" onchange="sortUsers()">
|
||||
<option value="created_at">按注册时间</option>
|
||||
<option value="username">按用户名</option>
|
||||
<option value="last_login">按最后登录</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户统计 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-primary" id="totalUsers">0</h5>
|
||||
<p class="card-text">总用户数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success" id="activeUsers">0</h5>
|
||||
<p class="card-text">活跃用户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-warning" id="newUsers">0</h5>
|
||||
<p class="card-text">今日新增</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-info" id="onlineUsers">0</h5>
|
||||
<p class="card-text">在线用户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>手机号</th>
|
||||
<th>状态</th>
|
||||
<th>注册时间</th>
|
||||
<th>最后登录</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<nav aria-label="用户分页">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- 分页按钮将通过JavaScript生成 -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
padding: 48px 0 0;
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-left: 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let pageSize = 10;
|
||||
let totalPages = 1;
|
||||
|
||||
$(document).ready(function () {
|
||||
loadUsers();
|
||||
loadUserStats();
|
||||
});
|
||||
|
||||
function loadUsers(page = 1) {
|
||||
currentPage = page;
|
||||
|
||||
// 显示加载状态
|
||||
$('#userTableBody').html('<tr><td colspan="7" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
||||
|
||||
// 构建请求参数
|
||||
let params = {
|
||||
page: page,
|
||||
size: 10
|
||||
};
|
||||
|
||||
const keyword = $('#searchKeyword').val();
|
||||
const status = $('#statusFilter').val();
|
||||
|
||||
if (keyword && keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
if (status && status !== '') {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
// 调用真实API
|
||||
$.get('${pageContext.request.contextPath}/api/admin/users', params)
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
renderUsersTable(response.data.users);
|
||||
renderPagination(response.data.currentPage, response.data.totalPages);
|
||||
} else {
|
||||
$('#userTableBody').html('<tr><td colspan="7" class="text-center text-danger">获取用户数据失败: ' + response.message + '</td></tr>');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
$('#userTableBody').html('<tr><td colspan="7" class="text-center text-danger">网络请求失败,请稍后重试</td></tr>');
|
||||
});
|
||||
}
|
||||
|
||||
function loadUserStats() {
|
||||
// 调用真实API获取用户统计数据
|
||||
$.get('${pageContext.request.contextPath}/api/admin/users/stats')
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
updateUserStats(response.data);
|
||||
} else {
|
||||
console.error('获取用户统计数据失败:', response.message);
|
||||
// 显示默认值
|
||||
updateUserStats({});
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
console.error('获取用户统计数据请求失败');
|
||||
// 显示默认值
|
||||
updateUserStats({});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户统计数据
|
||||
function updateUserStats(stats) {
|
||||
$('#totalUsers').text(formatNumber(stats.totalUsers || 0));
|
||||
$('#activeUsers').text(formatNumber(stats.activeUsers || 0));
|
||||
$('#newUsers').text(formatNumber(stats.newUsers || 0));
|
||||
$('#onlineUsers').text(formatNumber(stats.onlineUsers || 0));
|
||||
}
|
||||
|
||||
function renderUsersTable(users) {
|
||||
let html = '';
|
||||
|
||||
if (users.length === 0) {
|
||||
html = '<tr><td colspan="7" class="text-center">暂无用户数据</td></tr>';
|
||||
} else {
|
||||
users.forEach(user => {
|
||||
html += `
|
||||
<tr>
|
||||
<td>` + user.id + `</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="me-2">` + user.username + `</span>
|
||||
` + (user.isOnline ? '<span class="badge bg-success">在线</span>' : '') + `
|
||||
</div>
|
||||
</td>
|
||||
<td>` + (user.email || '-') + `</td>
|
||||
<td>` + (user.phone || '-') + `</td>
|
||||
<td>
|
||||
<span class="badge ` + getUserStatusClass(user.status) + `">
|
||||
` + getUserStatusText(user.status) + `
|
||||
</span>
|
||||
</td>
|
||||
<td>` + formatDateTime(user.createdAt) + `</td>
|
||||
<td>` + (user.lastLogin ? formatDateTime(user.lastLogin) : '从未登录') + `</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
$('#usersTableBody').html(html);
|
||||
}
|
||||
|
||||
function renderPagination(total, pageSize) {
|
||||
totalPages = Math.ceil(total / pageSize);
|
||||
let html = '';
|
||||
|
||||
// 上一页
|
||||
html += `
|
||||
<li class="page-item ` + (currentPage === 1 ? 'disabled' : '') + `">
|
||||
<a class="page-link" href="#" onclick="loadUsers(` + (currentPage - 1) + `)">上一页</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// 页码
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
html += `
|
||||
<li class="page-item ` + (i === currentPage ? 'active' : '') + `">
|
||||
<a class="page-link" href="#" onclick="loadUsers(` + i + `)">` + i + `</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
// 下一页
|
||||
html += `
|
||||
<li class="page-item ` + (currentPage === totalPages ? 'disabled' : '') + `">
|
||||
<a class="page-link" href="#" onclick="loadUsers(` + (currentPage + 1) + `)">下一页</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
$('#pagination').html(html);
|
||||
}
|
||||
|
||||
function refreshUsers() {
|
||||
$('#usersTableBody').html('<tr><td colspan="7" class="text-center"><i class="fas fa-spinner fa-spin"></i> 加载中...</td></tr>');
|
||||
loadUsers(currentPage);
|
||||
loadUserStats();
|
||||
}
|
||||
|
||||
function searchUsers() {
|
||||
const keyword = $('#searchInput').val();
|
||||
console.log('搜索用户:', keyword);
|
||||
loadUsers(1);
|
||||
}
|
||||
|
||||
function filterUsers() {
|
||||
const status = $('#statusFilter').val();
|
||||
const date = $('#dateFilter').val();
|
||||
console.log('筛选用户:', {status, date});
|
||||
loadUsers(1);
|
||||
}
|
||||
|
||||
function sortUsers() {
|
||||
const sortBy = $('#sortBy').val();
|
||||
console.log('排序方式:', sortBy);
|
||||
loadUsers(1);
|
||||
}
|
||||
|
||||
|
||||
function exportUsers() {
|
||||
console.log('导出用户数据');
|
||||
alert('用户数据导出功能开发中...');
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatDateTime(dateTime) {
|
||||
if (!dateTime) return '';
|
||||
return new Date(dateTime).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function getUserStatusClass(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'bg-success'; // 正常
|
||||
case 0:
|
||||
return 'bg-danger'; // 禁用
|
||||
default:
|
||||
return 'bg-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getUserStatusText(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '正常';
|
||||
case 0:
|
||||
return '禁用';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<%@ include file="../common/footer.jsp" %>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user