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:
@@ -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>
|
||||
</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>
|
||||
</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;
|
||||
}
|
||||
|
||||
.tech-tag {
|
||||
padding: 2px 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
.footer-link {
|
||||
color: #5e5e58;
|
||||
|
||||
&:hover {
|
||||
color: #171715;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.tech-tag {
|
||||
padding: 4px 10px;
|
||||
background-color: #fffaf2;
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #5c5346;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
<nav class="flex items-center justify-between h-16">
|
||||
<!-- 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>
|
||||
<router-link to="/products" class="nav-link">
|
||||
<el-icon><ShoppingBag /></el-icon>
|
||||
商品列表
|
||||
<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(() =>
|
||||
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(() =>
|
||||
const flashsaleNotifications = computed(() =>
|
||||
notifications.value.filter(n => n.type === 'flashsale')
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
)
|
||||
|
||||
const orderNotifications = computed(() =>
|
||||
const orderNotifications = computed(() =>
|
||||
notifications.value.filter(n => n.type === 'order')
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
)
|
||||
|
||||
const systemNotifications = computed(() =>
|
||||
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) {
|
||||
notification.read = true
|
||||
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 = () => {
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await notificationApi.markAllAsRead()
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 清空消息
|
||||
const clearAll = () => {
|
||||
notifications.value = []
|
||||
visible.value = false
|
||||
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,14 +394,14 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
@@ -452,4 +460,4 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,12 @@
|
||||
<el-icon><Lightning /></el-icon>
|
||||
<template #title>秒杀管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
|
||||
<el-menu-item index="/admin/groupbuying">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<template #title>拼团管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/orders">
|
||||
<el-icon><List /></el-icon>
|
||||
<template #title>订单管理</template>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 路由切换动画
|
||||
@@ -45,4 +45,4 @@ import AppFooter from '@/components/common/AppFooter.vue'
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -166,13 +166,10 @@ const loadFlashSales = async () => {
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size
|
||||
})
|
||||
|
||||
|
||||
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>
|
||||
</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
|
||||
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', {
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '分享一下你的使用感受吧',
|
||||
confirmButtonText: '提交评价',
|
||||
cancelButtonText: '取消',
|
||||
const checkOrdersReviewStatus = async (orderList: Order[]) => {
|
||||
const completed = orderList.filter(o => o.status === 'COMPLETED')
|
||||
await Promise.all(
|
||||
completed.map(async (order) => {
|
||||
try {
|
||||
const checks = await Promise.all(
|
||||
order.items.map(item => reviewApi.checkReview(order.id, item.productId).catch(() => null))
|
||||
)
|
||||
orderReviewStatus.value[order.id] = checks.every(res => res?.success && res.data.reviewed)
|
||||
} catch {
|
||||
orderReviewStatus.value[order.id] = false
|
||||
}
|
||||
})
|
||||
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 {
|
||||
@@ -362,4 +410,4 @@ onMounted(() => {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -4,28 +4,50 @@
|
||||
<!-- 页面标题 -->
|
||||
<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">
|
||||
<!-- 分类筛选 -->
|
||||
<el-select
|
||||
v-model="filters.category"
|
||||
<el-select
|
||||
v-model="filters.category"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@change="loadProducts"
|
||||
>
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:label="cat"
|
||||
:value="cat"
|
||||
:label="cat"
|
||||
:value="cat"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
.page-icon {
|
||||
color: #44443f;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
font-size: 14px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,7 +94,7 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
.page-mark {
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.register-panel {
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 24px;
|
||||
background: #fffaf2;
|
||||
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="user-reviews-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>我的评价</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">我的评价</h1>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="reviews.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||
<el-empty description="暂无评价,去购物吧">
|
||||
<el-button type="primary" @click="router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="review in paginatedReviews" :key="review.id" class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex gap-4">
|
||||
<SafeImage
|
||||
:src="review.productImage"
|
||||
:alt="review.productName"
|
||||
wrapper-class="w-20 h-20 rounded cursor-pointer"
|
||||
img-class="w-20 h-20 object-cover rounded"
|
||||
@click="router.push(`/product/${review.productId}`)"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold cursor-pointer hover:text-blue-500 inline"
|
||||
@click="router.push(`/product/${review.productId}`)"
|
||||
>
|
||||
{{ review.productName || '商品' }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="review.orderId"
|
||||
class="ml-3 text-xs text-gray-400 cursor-pointer hover:text-blue-400"
|
||||
@click="router.push(`/order/${review.orderId}`)"
|
||||
>
|
||||
订单 #{{ review.orderId }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</span>
|
||||
</div>
|
||||
<el-rate :model-value="review.rating" disabled />
|
||||
<p class="text-gray-600 mt-2 leading-6">{{ review.content }}</p>
|
||||
|
||||
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm">
|
||||
<div class="font-medium text-gray-800 mb-1">商家回复</div>
|
||||
<div class="text-gray-600">{{ review.adminReply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reviews.length > pageSize" class="mt-8 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="reviews.length"
|
||||
layout="prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import type { ReviewItem } from '@/api/modules/review'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const reviews = ref<ReviewItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
|
||||
const paginatedReviews = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return reviews.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
const loadReviews = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await reviewApi.getMyReviews()
|
||||
if (res.success) {
|
||||
reviews.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评价失败:', error)
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-reviews-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -80,6 +80,36 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/user/favorites.vue'),
|
||||
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',
|
||||
|
||||
@@ -34,16 +34,32 @@ export const useUserStore = defineStore('user', () => {
|
||||
if (res.success) {
|
||||
token.value = res.data.token
|
||||
user.value = res.data.user
|
||||
|
||||
// 保存到localStorage
|
||||
|
||||
localStorage.setItem('token', token.value)
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
|
||||
try {
|
||||
const profile = await userApi.getInfo()
|
||||
if (profile.success) {
|
||||
user.value = {
|
||||
...profile.data,
|
||||
avatar: profile.data.avatar || user.value?.avatar || '',
|
||||
}
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.error('登录成功但会话校验失败:', sessionError)
|
||||
user.value = null
|
||||
token.value = ''
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
ElMessage.error('登录成功但会话未建立,请检查 Cookie / 代理配置')
|
||||
return false
|
||||
}
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 跳转到之前的页面或首页
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
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
@@ -155,4 +155,65 @@ export interface Statistics {
|
||||
todaySales: number
|
||||
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: {
|
||||
@@ -26,4 +26,4 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user