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:
@@ -94,7 +94,7 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.favorites-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="login-page min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="login-panel bg-white p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<el-icon :size="48" class="text-red-500 mb-4">
|
||||
<el-icon :size="48" class="page-mark mb-4">
|
||||
<Lightning />
|
||||
</el-icon>
|
||||
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
||||
@@ -59,20 +59,6 @@
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider>或</el-divider>
|
||||
|
||||
<!-- 快速登录 -->
|
||||
<div class="mb-4">
|
||||
<el-button size="large" class="w-full mb-2" @click="quickLogin('user')">
|
||||
<el-icon class="mr-2"><User /></el-icon>
|
||||
普通用户快速登录
|
||||
</el-button>
|
||||
<el-button size="large" class="w-full" @click="quickLogin('admin')">
|
||||
<el-icon class="mr-2"><Setting /></el-icon>
|
||||
管理员快速登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<span class="text-gray-600">还没有账号?</span>
|
||||
<router-link to="/register" class="text-primary-500 hover:underline">
|
||||
@@ -81,15 +67,6 @@
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 测试账号提示 -->
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-900 mb-2">测试账号</h3>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p>普通用户: demo1 / 123456</p>
|
||||
<p>管理员: admin / admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,23 +117,21 @@ const handleLogin = async () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 快速登录
|
||||
const quickLogin = (type: 'user' | 'admin') => {
|
||||
if (type === 'user') {
|
||||
loginForm.username = 'demo1'
|
||||
loginForm.password = '123456'
|
||||
} else {
|
||||
loginForm.username = 'admin'
|
||||
loginForm.password = 'admin123'
|
||||
}
|
||||
loginForm.rememberMe = true
|
||||
handleLogin()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 24px;
|
||||
background: #fffaf2;
|
||||
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
|
||||
}
|
||||
|
||||
.page-mark {
|
||||
color: #171715;
|
||||
}
|
||||
</style>
|
||||
|
||||
184
flash-sale-frontend/src/pages/user/notifications.vue
Normal file
184
flash-sale-frontend/src/pages/user/notifications.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto py-6 px-4">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>消息通知</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">消息通知</h2>
|
||||
<div class="flex gap-2">
|
||||
<el-button size="small" @click="handleMarkAllRead" :disabled="unreadCount === 0">
|
||||
全部已读
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleClearAll" :disabled="notifications.length === 0">
|
||||
清空全部
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签筛选 -->
|
||||
<el-tabs v-model="activeType" @tab-change="loadNotifications">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="秒杀" name="flashsale" />
|
||||
<el-tab-pane label="订单" name="order" />
|
||||
<el-tab-pane label="系统" name="system" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="32" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<div v-else-if="notifications.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50"
|
||||
:class="{ 'bg-orange-50/50 border-orange-200': !item.read }"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<el-icon :size="20" class="mt-0.5" :class="getIconColor(item.type)">
|
||||
<component :is="getIcon(item.type)" />
|
||||
</el-icon>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium" :class="{ 'font-semibold': !item.read }">{{ item.title }}</span>
|
||||
<el-tag v-if="!item.read" type="danger" size="small" effect="light">未读</el-tag>
|
||||
<el-tag size="small" effect="plain">{{ getTypeLabel(item.type) }}</el-tag>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-2">{{ item.message }}</p>
|
||||
<span class="text-xs text-gray-400">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!item.read"
|
||||
text
|
||||
size="small"
|
||||
@click.stop="handleMarkRead(item)"
|
||||
>
|
||||
标记已读
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-else description="暂无消息通知" class="py-12" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { notificationApi } from '@/api/modules/notification'
|
||||
import type { NotificationItem } from '@/api/modules/notification'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const activeType = ref('all')
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
|
||||
|
||||
const loadNotifications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const type = activeType.value === 'all' ? undefined : activeType.value
|
||||
const res = await notificationApi.getList(type)
|
||||
if (res?.success) {
|
||||
notifications.value = res.data || []
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取通知失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (item: NotificationItem) => {
|
||||
try {
|
||||
await notificationApi.markAsRead(item.id)
|
||||
item.read = true
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await notificationApi.markAllAsRead()
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
ElMessage.success('已全部标记为已读')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要清空所有通知吗?', '提示', { type: 'warning' })
|
||||
await notificationApi.clearAll()
|
||||
notifications.value = []
|
||||
ElMessage.success('已清空所有通知')
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = async (item: NotificationItem) => {
|
||||
if (!item.read) {
|
||||
await notificationApi.markAsRead(item.id).catch(() => {})
|
||||
item.read = true
|
||||
}
|
||||
if (item.link) {
|
||||
router.push(item.link)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return dayjs(dateStr).fromNow()
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
flashsale: 'Lightning',
|
||||
order: 'List',
|
||||
system: 'InfoFilled'
|
||||
}
|
||||
return icons[type] || 'InfoFilled'
|
||||
}
|
||||
|
||||
const getIconColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
flashsale: 'text-orange-500',
|
||||
order: 'text-blue-500',
|
||||
system: 'text-gray-500'
|
||||
}
|
||||
return colors[type] || 'text-gray-500'
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
flashsale: '秒杀',
|
||||
order: '订单',
|
||||
system: '系统'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications()
|
||||
})
|
||||
</script>
|
||||
@@ -326,16 +326,15 @@ onMounted(async () => {
|
||||
<style scoped lang="scss">
|
||||
.profile-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply rounded-lg p-5 text-white shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
@apply rounded-lg p-5 shadow-sm;
|
||||
background: #fffaf2;
|
||||
color: #171715;
|
||||
border: 1px solid #d8cebf;
|
||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="register-page min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="register-panel bg-white p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<el-icon :size="48" class="text-red-500 mb-4">
|
||||
<el-icon :size="48" class="page-mark mb-4">
|
||||
<Lightning />
|
||||
</el-icon>
|
||||
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
|
||||
@@ -192,6 +192,17 @@ const handleRegister = async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.register-page {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
.page-mark {
|
||||
color: #171715;
|
||||
}
|
||||
|
||||
.register-panel {
|
||||
border: 1px solid #d8cebf;
|
||||
border-radius: 24px;
|
||||
background: #fffaf2;
|
||||
box-shadow: 0 14px 34px rgba(23, 22, 20, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
122
flash-sale-frontend/src/pages/user/reviews.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="user-reviews-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>我的评价</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">我的评价</h1>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="reviews.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||
<el-empty description="暂无评价,去购物吧">
|
||||
<el-button type="primary" @click="router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="review in paginatedReviews" :key="review.id" class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex gap-4">
|
||||
<SafeImage
|
||||
:src="review.productImage"
|
||||
:alt="review.productName"
|
||||
wrapper-class="w-20 h-20 rounded cursor-pointer"
|
||||
img-class="w-20 h-20 object-cover rounded"
|
||||
@click="router.push(`/product/${review.productId}`)"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold cursor-pointer hover:text-blue-500 inline"
|
||||
@click="router.push(`/product/${review.productId}`)"
|
||||
>
|
||||
{{ review.productName || '商品' }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="review.orderId"
|
||||
class="ml-3 text-xs text-gray-400 cursor-pointer hover:text-blue-400"
|
||||
@click="router.push(`/order/${review.orderId}`)"
|
||||
>
|
||||
订单 #{{ review.orderId }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</span>
|
||||
</div>
|
||||
<el-rate :model-value="review.rating" disabled />
|
||||
<p class="text-gray-600 mt-2 leading-6">{{ review.content }}</p>
|
||||
|
||||
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm">
|
||||
<div class="font-medium text-gray-800 mb-1">商家回复</div>
|
||||
<div class="text-gray-600">{{ review.adminReply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reviews.length > pageSize" class="mt-8 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="reviews.length"
|
||||
layout="prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import type { ReviewItem } from '@/api/modules/review'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const reviews = ref<ReviewItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
|
||||
const paginatedReviews = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return reviews.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
const loadReviews = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await reviewApi.getMyReviews()
|
||||
if (res.success) {
|
||||
reviews.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评价失败:', error)
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-reviews-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user