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

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