feat: 删除JSP视图层,完善评价和通知系统,新增拼团模块

- 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA
- 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API
- 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API
- 新增拼团模块:GroupBuying 全套后端和前端页面
- 修复 review check API 参数双重包装导致请求格式错误
- 修复通知 API 路径缺少 /api 前缀和响应格式处理
- MessageListenerService 集成 NotificationService 创建持久化通知

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 16:40:26 +08:00
parent b684ea38d4
commit c4582655d9
115 changed files with 5968 additions and 12623 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,11 @@ export interface ReviewItem {
userId: number
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}`)
},
}

View File

@@ -1,44 +0,0 @@
import request from './request'
import type { Product, ProductParams } from '@/types/product'
export const productApi = {
// 获取商品列表
getList(params?: ProductParams) {
return request.get<any, { list: Product[], total: number }>('/api/products', { params })
},
// 获取商品详情
getDetail(id: number) {
return request.get<any, Product>(`/api/products/${id}`)
},
// 获取热门商品
getHot(limit: number = 8) {
return request.get<any, Product[]>('/api/products/hot', {
params: { limit }
})
},
// 获取推荐商品
getRecommended(limit: number = 8) {
return request.get<any, Product[]>('/api/products/recommended', {
params: { limit }
})
},
// 搜索商品
search(keyword: string) {
return request.get<any, Product[]>('/api/products/search', {
params: { keyword }
})
},
// 按分类获取商品
getByCategory(categoryId: number) {
return request.get<any, Product[]>('/api/products/category', {
params: { categoryId }
})
}
}
export default productApi

View File

@@ -10,7 +10,8 @@ import router from '@/router'
// 创建axios实例
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',

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
{{ data.description || '暂无描述' }}
</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;

View File

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

View File

@@ -15,17 +15,17 @@
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -233,6 +233,7 @@ const renderChart = () => {
chart.setOption({
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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -329,7 +329,7 @@ onMounted(() => {
<style scoped lang="scss">
.cart-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
background: transparent;
}
.line-clamp-2 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,11 @@
<div class="container mx-auto px-4 h-full">
<div class="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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -94,7 +94,7 @@ onMounted(() => {
<style scoped lang="scss">
.favorites-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
background: transparent;
}
.line-clamp-1 {

View File

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

View File

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

View File

@@ -326,16 +326,15 @@ onMounted(async () => {
<style scoped lang="scss">
.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 {

View File

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

View File

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

View File

@@ -80,6 +80,36 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/user/favorites.vue'),
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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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