后端功能增强:全局异常处理、API控制器、JSP视图和单元测试

- 添加 GlobalExceptionHandler 全局异常处理
- 添加 ApiController REST API 控制器
- 更新 WebConfig 跨域配置和 ProductRepository 查询方法
- 新增 monitor/product-detail/profile JSP 视图页面
- 添加 FlashSaleServiceTest 秒杀服务单元测试
- 更新 application.yml 配置
This commit is contained in:
2026-03-05 20:30:48 +08:00
parent 923e877759
commit 989c2741a2
63 changed files with 15508 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
# 开发环境配置
VITE_APP_TITLE=秒杀系统
VITE_API_BASE_URL=http://localhost:8080
VITE_WS_URL=ws://localhost:8080/ws
VITE_UPLOAD_URL=http://localhost:8080/upload
VITE_TIMEOUT=10000
VITE_USE_MOCK=false

View File

@@ -0,0 +1,7 @@
# 生产环境配置
VITE_APP_TITLE=秒杀系统
VITE_API_BASE_URL=https://api.flashsale.com
VITE_WS_URL=wss://api.flashsale.com/ws
VITE_UPLOAD_URL=https://api.flashsale.com/upload
VITE_TIMEOUT=10000
VITE_USE_MOCK=false

View File

@@ -0,0 +1 @@
cache=/tmp/npm-cache

View File

@@ -0,0 +1,189 @@
# 秒杀系统前端 (Flash Sale Frontend)
基于 Vue 3 + Vite + TypeScript 构建的现代化秒杀系统前端应用。
## 🚀 技术栈
- **框架**: Vue 3.4 (Composition API)
- **构建工具**: Vite 5.0
- **编程语言**: TypeScript 5.3
- **UI组件库**: Element Plus 2.4
- **状态管理**: Pinia 2.1
- **路由**: Vue Router 4.2
- **HTTP客户端**: Axios 1.6
- **样式**: TailwindCSS 3.4 + Sass
- **图表**: ECharts 5.4
- **工具库**: VueUse, Day.js, Lodash
## ✨ 功能特性
### 用户端功能
- 🏠 **首页展示**: 轮播图、秒杀活动、热门商品推荐
- 🔐 **用户认证**: 登录、注册、个人中心管理
-**秒杀系统**: 实时倒计时、库存显示、防重复提交
- 🛍️ **商品浏览**: 分类筛选、价格排序、关键词搜索
- 🛒 **购物车**: 商品管理、批量操作、结算功能
- 📦 **订单管理**: 订单列表、详情查看、状态跟踪
### 管理后台
- 📊 **数据仪表盘**: 实时统计、图表展示
- 📝 **内容管理**: 商品、秒杀、订单、用户管理
- 📈 **数据分析**: 销售趋势、用户行为分析
### 高级特性
- 🔔 **实时通知**: WebSocket消息推送、消息中心
- 🔍 **智能搜索**: 搜索历史、热门推荐、实时建议
- 📸 **图片上传**: 拖拽上传、预览下载、进度显示
- 📊 **数据导出**: Excel导出、批量处理
- 🎨 **响应式设计**: 移动端适配、深色模式(开发中)
## 📁 项目结构
```
flash-sale-frontend/
├── src/
│ ├── api/ # API接口封装
│ ├── assets/ # 静态资源
│ ├── components/ # 组件
│ │ ├── common/ # 通用组件
│ │ ├── business/ # 业务组件
│ │ └── layout/ # 布局组件
│ ├── composables/ # 组合式函数
│ ├── layouts/ # 页面布局
│ ├── pages/ # 页面视图
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia状态管理
│ ├── styles/ # 全局样式
│ ├── types/ # TypeScript类型定义
│ └── utils/ # 工具函数
├── public/ # 公共资源
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
└── vite.config.ts # Vite配置
```
## 🔧 安装使用
### 环境要求
- Node.js >= 16.0
- npm >= 8.0 或 yarn >= 1.22
### 安装依赖
```bash
npm install
# 或
yarn install
```
### 开发环境
```bash
npm run dev
# 或
yarn dev
```
访问 http://localhost:3000
### 生产构建
```bash
npm run build
# 或
yarn build
```
### 预览构建结果
```bash
npm run preview
# 或
yarn preview
```
## 🔑 测试账号
- **普通用户**: user / 123456
- **管理员**: admin / 123456
## 📝 配置说明
### 环境变量
修改 `.env.development``.env.production` 文件:
```env
# API基础路径
VITE_API_BASE_URL=http://localhost:8080
# WebSocket地址
VITE_WS_URL=ws://localhost:8080/ws
# 上传地址
VITE_UPLOAD_URL=http://localhost:8080/upload
```
### 代理配置
`vite.config.ts` 中配置开发环境代理:
```typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
```
## 🚢 部署
### Nginx配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/dist;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend-server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Docker部署
```dockerfile
FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
## 🎯 性能优化
- **路由懒加载**: 减少首屏加载时间
- **组件缓存**: keep-alive缓存页面状态
- **图片懒加载**: 按需加载图片资源
- **代码分割**: 自动分包优化
- **Gzip压缩**: 减少传输体积
- **CDN加速**: 静态资源CDN分发
## 📄 许可证
MIT License
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📧 联系我们
- 邮箱: contact@flashsale.com
- 官网: https://flashsale.com
---
Made with ❤️ by Flash Sale Team

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>秒杀系统 - 高并发电商抢购平台</title>
<meta name="description" content="基于Redis集群构建的高并发秒杀系统支持分布式锁、接口限流、库存预热等核心功能">
<meta name="keywords" content="秒杀,抢购,电商,flash sale">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5602
flash-sale-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"name": "flash-sale-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"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/"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.1",
"dayjs": "^1.11.10",
"@vueuse/core": "^10.7.1",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.8",
"lodash-es": "^4.17.21",
"@types/lodash-es": "^4.17.12",
"xlsx": "^0.18.5",
"file-saver": "^2.0.5",
"@types/file-saver": "^2.0.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11",
"typescript": "^5.3.3",
"vue-tsc": "^1.8.27",
"@types/node": "^20.11.5",
"sass": "^1.70.0",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.1",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"prettier": "^3.2.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,26 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
onMounted(() => {
// 初始化用户信息
userStore.getUserInfo()
})
</script>
<style>
#app {
min-height: 100vh;
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,42 @@
import { request } from '../request'
import type { ApiResponse, CartItem } from '@/types/api'
export const cartApi = {
// 获取购物车
getCart(): Promise<ApiResponse<CartItem[]>> {
return request.get('/api/cart')
},
// 添加到购物车
addToCart(data: {
productId: number;
quantity: number
}): Promise<ApiResponse> {
return request.post('/api/cart/add', data)
},
// 更新数量
updateQuantity(itemId: string, quantity: number): Promise<ApiResponse> {
return request.put(`/api/cart/item/${itemId}`, { quantity })
},
// 删除商品
removeItem(itemId: string): Promise<ApiResponse> {
return request.delete(`/api/cart/item/${itemId}`)
},
// 批量删除
batchRemove(ids: string[]): Promise<ApiResponse> {
return request.post('/api/cart/batch-remove', { ids })
},
// 清空购物车
clearCart(): Promise<ApiResponse> {
return request.delete('/api/cart/clear')
},
// 获取购物车数量
getCount(): Promise<ApiResponse<{ count: number }>> {
return request.get('/api/cart/count')
},
}

View File

@@ -0,0 +1,42 @@
import { request } from '../request'
import type { ApiResponse, FlashSale, PageParams, PageResponse } from '@/types/api'
export const flashsaleApi = {
// 获取秒杀活动列表
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<FlashSale>>> {
return request.get('/api/flashsale/list', params)
},
// 获取正在进行的秒杀活动
getActive(limit?: number): Promise<ApiResponse<FlashSale[]>> {
return request.get('/api/flashsale/active', { limit })
},
// 获取秒杀活动详情
getDetail(id: number): Promise<ApiResponse<FlashSale>> {
return request.get(`/api/flashsale/${id}`)
},
// 参与秒杀
participate(data: {
flashSaleId: number;
quantity: number;
timestamp?: number;
}): Promise<ApiResponse<{ orderId: number }>> {
return request.post('/api/flashsale/participate', data)
},
// 获取用户参与记录
getUserRecords(): Promise<ApiResponse<any[]>> {
return request.get('/api/flashsale/user-records')
},
// 检查用户是否可以参与
checkEligibility(flashSaleId: number): Promise<ApiResponse<{
eligible: boolean;
reason?: string;
remainingQuota?: number;
}>> {
return request.get(`/api/flashsale/${flashSaleId}/check-eligibility`)
},
}

View File

@@ -0,0 +1,57 @@
import { request } from '../request'
import type { ApiResponse, Order, PageParams, PageResponse } from '@/types/api'
export const orderApi = {
// 创建订单
create(data: {
items: Array<{ productId: number; quantity: number }>
addressId?: number
remark?: string
}): Promise<ApiResponse<Order>> {
return request.post('/api/order/create', data)
},
// 获取订单列表
getList(params?: PageParams & {
status?: string
}): Promise<ApiResponse<PageResponse<Order>>> {
return request.get('/api/order/list', params)
},
// 获取订单详情
getDetail(id: number): Promise<ApiResponse<Order>> {
return request.get(`/api/order/${id}`)
},
// 取消订单
cancel(id: number): Promise<ApiResponse> {
return request.post(`/api/order/${id}/cancel`)
},
// 支付订单
pay(id: number, paymentMethod: string): Promise<ApiResponse> {
return request.post(`/api/order/${id}/pay`, { paymentMethod })
},
// 确认收货
confirm(id: number): Promise<ApiResponse> {
return request.post(`/api/order/${id}/confirm`)
},
// 删除订单
delete(id: number): Promise<ApiResponse> {
return request.delete(`/api/order/${id}`)
},
// 获取订单统计
getStatistics(): Promise<ApiResponse<{
total: number
pending: number
paid: number
shipped: number
completed: number
cancelled: number
}>> {
return request.get('/api/order/statistics')
},
}

View File

@@ -0,0 +1,34 @@
import { request } from '../request'
import type { ApiResponse, Product, PageParams, PageResponse } from '@/types/api'
export const productApi = {
// 获取商品列表
getList(params?: PageParams & {
keyword?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
}): Promise<ApiResponse<PageResponse<Product>>> {
return request.get('/api/product/list', params)
},
// 获取热门商品
getHot(limit = 8): Promise<ApiResponse<Product[]>> {
return request.get('/api/product/hot', { limit })
},
// 获取商品详情
getDetail(id: number): Promise<ApiResponse<Product>> {
return request.get(`/api/product/${id}`)
},
// 搜索商品
search(keyword: string): Promise<ApiResponse<Product[]>> {
return request.get('/api/product/search', { keyword })
},
// 获取商品分类
getCategories(): Promise<ApiResponse<string[]>> {
return request.get('/api/product/categories')
},
}

View File

@@ -0,0 +1,34 @@
import { request } from '../request'
import type { ApiResponse, User, LoginParams, RegisterParams } from '@/types/api'
export const userApi = {
// 登录
login(params: LoginParams): Promise<ApiResponse<{ token: string; user: User }>> {
return request.post('/api/auth/login', params)
},
// 注册
register(params: RegisterParams): Promise<ApiResponse<User>> {
return request.post('/api/auth/register', params)
},
// 退出登录
logout(): Promise<ApiResponse> {
return request.post('/api/auth/logout')
},
// 获取用户信息
getInfo(): Promise<ApiResponse<User>> {
return request.get('/api/user/info')
},
// 更新用户信息
updateInfo(data: Partial<User>): Promise<ApiResponse<User>> {
return request.put('/api/user/info', data)
},
// 修改密码
changePassword(data: { oldPassword: string; newPassword: string }): Promise<ApiResponse> {
return request.post('/api/user/change-password', data)
},
}

View File

@@ -0,0 +1,118 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
// 创建axios实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: Number(import.meta.env.VITE_TIMEOUT) || 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
const userStore = useUserStore()
// 添加token
if (userStore.token) {
config.headers = config.headers || {}
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 自定义状态码处理
if (res.code !== 200 && res.code !== 0) {
// 业务错误
if (res.code === 401) {
// 未登录或token失效
ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
})
} else if (res.code === 403) {
// 无权限
ElMessage.error('无权限访问')
} else {
// 其他业务错误
ElMessage.error(res.message || '请求失败')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
if (error.response) {
switch (error.response.status) {
case 401:
ElMessage.error('未授权,请登录')
break
case 403:
ElMessage.error('拒绝访问')
break
case 404:
ElMessage.error('请求地址不存在')
break
case 429:
ElMessage.error('请求过于频繁,请稍后再试')
break
case 500:
ElMessage.error('服务器内部错误')
break
default:
ElMessage.error(error.response.data?.message || '请求失败')
}
} else if (error.request) {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 通用请求方法
export const request = {
get<T = any>(url: string, params?: any): Promise<T> {
return service.get(url, { params })
},
post<T = any>(url: string, data?: any): Promise<T> {
return service.post(url, data)
},
put<T = any>(url: string, data?: any): Promise<T> {
return service.put(url, data)
},
delete<T = any>(url: string, params?: any): Promise<T> {
return service.delete(url, { params })
},
}
export default service

View File

@@ -0,0 +1,71 @@
<template>
<div class="countdown-timer">
<template v-if="timeLeft > 0">
<el-icon class="text-red-500 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>
<span class="separator">:</span>
<span class="time-block">{{ seconds.toString().padStart(2, '0') }}</span>
</template>
<span v-else class="text-gray-400">已结束</span>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
endTime: number
}>()
const emit = defineEmits<{
finish: []
}>()
const timeLeft = ref(0)
let timer: number | null = null
const hours = computed(() => Math.floor(timeLeft.value / 3600))
const minutes = computed(() => Math.floor((timeLeft.value % 3600) / 60))
const seconds = computed(() => timeLeft.value % 60)
const updateTime = () => {
const now = Date.now()
const remaining = Math.max(0, Math.floor((props.endTime - now) / 1000))
timeLeft.value = remaining
if (remaining === 0 && timer) {
clearInterval(timer)
timer = null
emit('finish')
}
}
onMounted(() => {
updateTime()
if (timeLeft.value > 0) {
timer = setInterval(updateTime, 1000)
}
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style scoped lang="scss">
.countdown-timer {
@apply flex items-center justify-center text-lg font-mono;
.time-block {
@apply px-2 py-1 bg-red-50 text-red-600 rounded;
}
.separator {
@apply mx-1 text-red-500 font-bold;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="flash-sale-card card-shadow">
<div class="relative">
<!-- 商品图片 -->
<img
:src="data.productImageUrl || '/default-product.png'"
:alt="data.productName"
class="w-full h-48 object-cover"
@error="handleImageError"
>
<!-- 状态标签 -->
<div class="absolute top-2 left-2">
<el-tag :type="statusType" effect="dark" size="small">
<el-icon class="mr-1"><Lightning /></el-icon>
{{ statusText }}
</el-tag>
</div>
<!-- 折扣标签 -->
<div class="absolute top-2 right-2">
<span class="discount-badge">
{{ discountPercent }}% OFF
</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-3">
<span class="text-2xl font-bold text-red-500">¥{{ data.flashPrice }}</span>
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.originalPrice }}</span>
</div>
<!-- 库存进度 -->
<div class="mb-3">
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>剩余: {{ data.remainingStock }}</span>
<span>{{ stockPercent }}%</span>
</div>
<el-progress
:percentage="stockPercent"
:stroke-width="6"
:show-text="false"
:color="progressColor"
/>
</div>
<!-- 倒计时 -->
<div class="text-center mb-3">
<CountDown
v-if="data.status === 'ACTIVE'"
:end-time="endTime"
@finish="$emit('refresh')"
/>
<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-icon class="mr-1"><Lightning /></el-icon>
{{ buttonText }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FlashSale } from '@/types/api'
import CountDown from './CountDown.vue'
const props = defineProps<{
data: FlashSale
}>()
const emit = defineEmits<{
participate: [id: number]
refresh: []
}>()
const loading = ref(false)
// 计算属性
const statusType = computed(() => {
switch (props.data.status) {
case 'UPCOMING': return 'warning'
case 'ACTIVE': return 'danger'
case 'ENDED': return 'info'
default: return 'info'
}
})
const statusText = computed(() => {
switch (props.data.status) {
case 'UPCOMING': return '即将开始'
case 'ACTIVE': return '秒杀中'
case 'ENDED': return '已结束'
default: return '未知'
}
})
const discountPercent = computed(() => {
return Math.round((1 - props.data.flashPrice / props.data.originalPrice) * 100)
})
const stockPercent = computed(() => {
if (props.data.flashStock === 0) return 0
return Math.round(props.data.remainingStock / props.data.flashStock * 100)
})
const progressColor = computed(() => {
if (stockPercent.value > 50) return '#67c23a'
if (stockPercent.value > 20) return '#e6a23c'
return '#f56c6c'
})
const endTime = computed(() => {
return new Date(props.data.endTime).getTime()
})
const canParticipate = computed(() => {
return 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 handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 参与秒杀
const handleParticipate = async () => {
if (!canParticipate.value) return
loading.value = true
emit('participate', props.data.id)
setTimeout(() => {
loading.value = false
}, 1000)
}
</script>
<style scoped lang="scss">
.flash-sale-card {
@apply bg-white rounded-lg overflow-hidden;
transition: all 0.3s;
&:hover {
transform: translateY(-4px);
}
}
.discount-badge {
@apply px-2 py-1 bg-orange-500 text-white text-xs font-bold rounded;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="product-card card-shadow">
<!-- 商品图片 -->
<div class="relative overflow-hidden h-48">
<img
:src="data.imageUrl || '/default-product.png'"
:alt="data.name"
class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
@error="handleImageError"
>
<!-- 库存标签 -->
<div v-if="data.stock <= 10" class="absolute top-2 right-2">
<el-tag type="warning" size="small">
仅剩 {{ data.stock }}
</el-tag>
</div>
</div>
<div class="p-4">
<!-- 商品名称 -->
<h3 class="font-semibold text-base mb-2 truncate">{{ data.name }}</h3>
<!-- 商品描述 -->
<p class="text-sm text-gray-500 mb-3 line-clamp-2">
{{ 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="text-sm text-gray-400">库存: {{ data.stock }}</span>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<el-button
type="primary"
size="small"
class="flex-1"
:disabled="data.stock === 0"
@click="handleAddToCart"
>
<el-icon class="mr-1"><ShoppingCart /></el-icon>
加入购物车
</el-button>
<el-button
size="small"
@click="handleViewDetail"
>
<el-icon><View /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import type { Product } from '@/types/api'
const props = defineProps<{
data: Product
}>()
const emit = defineEmits<{
addToCart: [id: number]
}>()
const router = useRouter()
// 处理图片加载错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 添加到购物车
const handleAddToCart = () => {
if (props.data.stock > 0) {
emit('addToCart', props.data.id)
}
}
// 查看详情
const handleViewDetail = () => {
router.push(`/product/${props.data.id}`)
}
</script>
<style scoped lang="scss">
.product-card {
@apply bg-white rounded-lg overflow-hidden;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
}
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<footer class="app-footer">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- 关于我们 -->
<div>
<h3 class="text-lg font-semibold mb-4">关于我们</h3>
<p class="text-gray-600 text-sm">
基于Redis集群构建的高并发秒杀系统支持分布式锁接口限流库存预热等核心功能
</p>
</div>
<!-- 快速链接 -->
<div>
<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>
</li>
<li>
<router-link to="/flashsale" class="text-gray-600 hover:text-primary-500">
秒杀活动
</router-link>
</li>
<li>
<router-link to="/products" class="text-gray-600 hover:text-primary-500">
商品列表
</router-link>
</li>
</ul>
</div>
<!-- 技术栈 -->
<div>
<h3 class="text-lg font-semibold mb-4">技术栈</h3>
<div class="flex flex-wrap gap-2">
<span class="tech-tag">Vue 3</span>
<span class="tech-tag">Vite</span>
<span class="tech-tag">TypeScript</span>
<span class="tech-tag">Element Plus</span>
<span class="tech-tag">Pinia</span>
<span class="tech-tag">TailwindCSS</span>
</div>
</div>
<!-- 联系我们 -->
<div>
<h3 class="text-lg font-semibold mb-4">联系我们</h3>
<div class="space-y-2 text-gray-600">
<p class="flex items-center">
<el-icon class="mr-2"><Message /></el-icon>
contact@flashsale.com
</p>
<p class="flex items-center">
<el-icon class="mr-2"><Phone /></el-icon>
400-123-4567
</p>
</div>
</div>
</div>
<div class="border-t mt-8 pt-8 text-center text-gray-500 text-sm">
<p>&copy; 2024 秒杀系统. All rights reserved.</p>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.app-footer {
background: white;
border-top: 1px solid #e5e5e5;
margin-top: auto;
}
.tech-tag {
padding: 2px 8px;
background-color: #f0f0f0;
border-radius: 4px;
font-size: 12px;
color: #666;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<header class="app-header">
<div class="container mx-auto px-4">
<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">
<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">
FLASH SALE
</span>
</router-link>
</div>
<!-- 导航菜单 -->
<div class="hidden md:flex items-center space-x-8">
<router-link to="/" class="nav-link">
<el-icon><HomeFilled /></el-icon>
首页
</router-link>
<router-link to="/flashsale" class="nav-link">
<el-icon><Lightning /></el-icon>
秒杀活动
</router-link>
<router-link to="/products" class="nav-link">
<el-icon><ShoppingBag /></el-icon>
商品列表
</router-link>
</div>
<!-- 右侧菜单 -->
<div class="flex items-center space-x-4">
<!-- 搜索框 -->
<el-input
v-model="searchKeyword"
placeholder="搜索商品"
class="w-48"
@keyup.enter="handleSearch"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="handleSearch">
<Search />
</el-icon>
</template>
</el-input>
<!-- 通知中心 -->
<NotificationCenter v-if="userStore.isLoggedIn" />
<!-- 购物车 -->
<router-link to="/cart" class="relative">
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
<el-icon :size="20"><ShoppingCart /></el-icon>
</el-badge>
</router-link>
<!-- 用户菜单 -->
<template v-if="userStore.isLoggedIn">
<el-dropdown trigger="click">
<div class="flex items-center space-x-2 cursor-pointer">
<el-avatar :size="32" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
</el-avatar>
<span class="hidden md:inline">{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item @click="router.push('/orders')">
<el-icon><List /></el-icon>
我的订单
</el-dropdown-item>
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
<el-icon><Setting /></el-icon>
管理后台
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<!-- 未登录 -->
<template v-else>
<el-button text @click="router.push('/login')">登录</el-button>
<el-button type="primary" @click="router.push('/register')">注册</el-button>
</template>
</div>
</nav>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import NotificationCenter from './NotificationCenter.vue'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const cartStore = useCartStore()
const searchKeyword = ref('')
const cartCount = ref(0)
// 搜索
const handleSearch = () => {
if (searchKeyword.value.trim()) {
router.push({
path: '/products',
query: { keyword: searchKeyword.value }
})
}
}
// 退出登录
const handleLogout = async () => {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
userStore.logout()
}
// 更新购物车数量
const updateCartCount = async () => {
if (userStore.isLoggedIn) {
cartCount.value = await cartStore.getCartCount()
}
}
onMounted(() => {
updateCartCount()
})
</script>
<style scoped lang="scss">
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.nav-link {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
color: #333;
text-decoration: none;
transition: all 0.3s;
&:hover {
color: var(--primary-color);
}
&.router-link-active {
color: var(--primary-color);
font-weight: 500;
}
}
.cart-badge {
:deep(.el-badge__content) {
background-color: var(--primary-color);
}
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<div class="image-upload">
<el-upload
:class="{ 'hide-upload': fileList.length >= limit }"
:action="uploadUrl"
:headers="headers"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:before-upload="beforeUpload"
:on-preview="handlePreview"
:on-remove="handleRemove"
:on-success="handleSuccess"
:on-error="handleError"
:on-exceed="handleExceed"
list-type="picture-card"
>
<template #default>
<el-icon class="upload-icon"><Plus /></el-icon>
</template>
<template #file="{ file }">
<div class="upload-file-item">
<img class="upload-image" :src="file.url" :alt="file.name" />
<span class="upload-actions">
<span class="upload-action" @click="handlePreview(file)">
<el-icon><ZoomIn /></el-icon>
</span>
<span class="upload-action" @click="handleDownload(file)">
<el-icon><Download /></el-icon>
</span>
<span class="upload-action" @click="handleRemove(file)">
<el-icon><Delete /></el-icon>
</span>
</span>
<!-- 上传进度 -->
<el-progress
v-if="file.status === 'uploading'"
type="circle"
:percentage="file.percentage"
class="upload-progress"
/>
</div>
</template>
</el-upload>
<!-- 图片预览 -->
<el-dialog
v-model="previewVisible"
title="图片预览"
width="800px"
append-to-body
>
<div class="preview-container">
<img :src="previewUrl" :alt="previewName" class="preview-image" />
<div class="preview-info">
<p><strong>文件名</strong>{{ previewName }}</p>
<p><strong>文件大小</strong>{{ formatFileSize(previewSize) }}</p>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import type { UploadFile, UploadFiles, UploadRawFile } from 'element-plus'
interface Props {
modelValue?: string | string[]
limit?: number
multiple?: boolean
accept?: string
maxSize?: number // MB
aspectRatio?: number // 宽高比
}
const props = withDefaults(defineProps<Props>(), {
limit: 1,
multiple: false,
accept: 'image/jpeg,image/jpg,image/png,image/gif,image/webp',
maxSize: 5, // 5MB
})
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
change: [value: string | string[]]
}>()
const userStore = useUserStore()
// 上传相关
const uploadUrl = computed(() => `${import.meta.env.VITE_API_BASE_URL}/api/upload/image`)
const headers = computed(() => ({
Authorization: `Bearer ${userStore.token}`
}))
const fileList = ref<UploadFile[]>([])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewName = ref('')
const previewSize = ref(0)
// 初始化文件列表
watch(
() => props.modelValue,
(val) => {
if (val) {
if (Array.isArray(val)) {
fileList.value = val.map((url, index) => ({
name: `image-${index}`,
url,
status: 'success',
uid: Date.now() + index
} as UploadFile))
} else {
fileList.value = [{
name: 'image',
url: val,
status: 'success',
uid: Date.now()
} as UploadFile]
}
}
},
{ immediate: true }
)
// 格式化文件大小
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else {
return (size / 1024 / 1024).toFixed(2) + ' MB'
}
}
// 上传前验证
const beforeUpload = (rawFile: UploadRawFile) => {
// 验证文件类型
const acceptTypes = props.accept.split(',').map(t => t.trim())
if (!acceptTypes.includes(rawFile.type)) {
ElMessage.error('只能上传图片文件!')
return false
}
// 验证文件大小
if (rawFile.size / 1024 / 1024 > props.maxSize) {
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB`)
return false
}
// 验证图片宽高比(可选)
if (props.aspectRatio) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const ratio = img.width / img.height
const targetRatio = props.aspectRatio!
const tolerance = 0.1 // 允许10%的误差
if (Math.abs(ratio - targetRatio) > tolerance) {
ElMessage.error(`图片宽高比不符合要求(需要 ${targetRatio}:1`)
reject(false)
} else {
resolve(true)
}
}
img.src = e.target?.result as string
}
reader.readAsDataURL(rawFile)
})
}
return true
}
// 上传成功
const handleSuccess = (response: any, file: UploadFile, files: UploadFiles) => {
if (response.success) {
file.url = response.data.url
updateValue()
ElMessage.success('上传成功')
} else {
handleRemove(file)
ElMessage.error(response.message || '上传失败')
}
}
// 上传失败
const handleError = (error: Error, file: UploadFile) => {
ElMessage.error('上传失败,请重试')
console.error('Upload error:', error)
}
// 超出数量限制
const handleExceed = (files: File[]) => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片`)
}
// 预览图片
const handlePreview = (file: UploadFile) => {
previewUrl.value = file.url!
previewName.value = file.name
previewSize.value = file.size || 0
previewVisible.value = true
}
// 下载图片
const handleDownload = (file: UploadFile) => {
const link = document.createElement('a')
link.href = file.url!
link.download = file.name
link.click()
}
// 删除图片
const handleRemove = (file: UploadFile) => {
const index = fileList.value.findIndex(f => f.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
updateValue()
}
}
// 更新值
const updateValue = () => {
const urls = fileList.value
.filter(f => f.status === 'success' && f.url)
.map(f => f.url!)
const value = props.multiple ? urls : urls[0] || ''
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style scoped lang="scss">
.image-upload {
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: 120px;
height: 120px;
}
}
:deep(.el-upload--picture-card) {
width: 120px;
height: 120px;
.upload-icon {
font-size: 28px;
color: #8c939d;
}
}
&.hide-upload {
:deep(.el-upload--picture-card) {
display: none;
}
}
.upload-file-item {
position: relative;
width: 100%;
height: 100%;
.upload-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-actions {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
cursor: default;
text-align: center;
color: #fff;
opacity: 0;
font-size: 20px;
background-color: rgba(0, 0, 0, 0.5);
transition: opacity 0.3s;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
.upload-action {
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
&:hover .upload-actions {
opacity: 1;
}
.upload-progress {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}
.preview-container {
.preview-image {
width: 100%;
max-height: 500px;
object-fit: contain;
}
.preview-info {
margin-top: 20px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
p {
margin: 5px 0;
font-size: 14px;
}
}
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="notification-center">
<el-popover
placement="bottom-end"
:width="380"
trigger="click"
:visible="visible"
@update:visible="val => visible = val"
>
<template #reference>
<div class="notification-trigger">
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
<el-icon :size="20"><Bell /></el-icon>
</el-badge>
</div>
</template>
<div class="notification-content">
<!-- 标题栏 -->
<div class="notification-header">
<span class="title">消息通知</span>
<div class="actions">
<el-button text size="small" @click="markAllAsRead">
全部已读
</el-button>
<el-button text size="small" @click="clearAll">
清空
</el-button>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="notification-tabs">
<el-tab-pane label="全部" name="all">
<div class="notification-list">
<div
v-for="item in allNotifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.read }"
@click="handleClick(item)"
>
<el-icon :size="16" :class="getIconClass(item.type)">
<component :is="getIcon(item.type)" />
</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>
<el-button
v-if="!item.read"
text
size="small"
@click.stop="markAsRead(item.id)"
>
标记已读
</el-button>
</div>
<el-empty v-if="allNotifications.length === 0" description="暂无消息" />
</div>
</el-tab-pane>
<el-tab-pane label="秒杀" name="flashsale">
<div class="notification-list">
<div
v-for="item in flashsaleNotifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.read }"
@click="handleClick(item)"
>
<el-icon :size="16" class="text-red-500">
<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>
</div>
<el-empty v-if="flashsaleNotifications.length === 0" description="暂无秒杀消息" />
</div>
</el-tab-pane>
<el-tab-pane label="订单" name="order">
<div class="notification-list">
<div
v-for="item in orderNotifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.read }"
@click="handleClick(item)"
>
<el-icon :size="16" class="text-blue-500">
<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>
</div>
<el-empty v-if="orderNotifications.length === 0" description="暂无订单消息" />
</div>
</el-tab-pane>
<el-tab-pane label="系统" name="system">
<div class="notification-list">
<div
v-for="item in systemNotifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.read }"
@click="handleClick(item)"
>
<el-icon :size="16" class="text-gray-500">
<InfoFilled />
</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>
</div>
<el-empty v-if="systemNotifications.length === 0" description="暂无系统消息" />
</div>
</el-tab-pane>
</el-tabs>
<!-- 底部 -->
<div class="notification-footer">
<router-link to="/notifications" class="view-all">
查看全部消息
</router-link>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWebSocket } from '@/composables/useWebSocket'
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 { subscribe, unsubscribe } = useWebSocket()
interface Notification {
id: string
type: 'flashsale' | 'order' | 'system'
title: string
message: string
timestamp: number
read: boolean
link?: string
}
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 unreadCount = computed(() =>
notifications.value.filter(n => !n.read).length
)
const allNotifications = computed(() =>
notifications.value.slice().sort((a, b) => b.timestamp - a.timestamp)
)
const flashsaleNotifications = computed(() =>
notifications.value.filter(n => n.type === 'flashsale')
.sort((a, b) => b.timestamp - a.timestamp)
)
const orderNotifications = computed(() =>
notifications.value.filter(n => n.type === 'order')
.sort((a, b) => b.timestamp - a.timestamp)
)
const systemNotifications = computed(() =>
notifications.value.filter(n => n.type === 'system')
.sort((a, b) => b.timestamp - a.timestamp)
)
// 格式化时间
const formatTime = (timestamp: number) => {
return dayjs(timestamp).fromNow()
}
// 获取图标
const getIcon = (type: string) => {
const icons: Record<string, string> = {
'flashsale': 'Lightning',
'order': 'List',
'system': 'InfoFilled'
}
return icons[type] || 'InfoFilled'
}
// 获取图标类名
const getIconClass = (type: string) => {
const classes: Record<string, string> = {
'flashsale': 'text-red-500',
'order': 'text-blue-500',
'system': 'text-gray-500'
}
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 markAllAsRead = () => {
notifications.value.forEach(n => n.read = true)
}
// 清空消息
const clearAll = () => {
notifications.value = []
visible.value = false
}
// 处理点击
const handleClick = (item: Notification) => {
markAsRead(item.id)
if (item.link) {
router.push(item.link)
visible.value = false
}
}
// 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)
})
onUnmounted(() => {
// 取消订阅
unsubscribe('FLASH_SALE_START', handleFlashSaleMessage)
unsubscribe('FLASH_SALE_END', handleFlashSaleMessage)
unsubscribe('ORDER_STATUS', handleOrderMessage)
unsubscribe('SYSTEM_NOTICE', handleSystemMessage)
})
</script>
<style scoped lang="scss">
.notification-center {
.notification-trigger {
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: var(--el-color-primary);
}
}
}
.notification-content {
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
.title {
font-size: 16px;
font-weight: 500;
}
.actions {
display: flex;
gap: 8px;
}
}
.notification-tabs {
:deep(.el-tabs__header) {
margin: 0;
padding: 0 16px;
}
:deep(.el-tabs__content) {
padding: 0;
}
}
.notification-list {
max-height: 400px;
overflow-y: auto;
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #f5f7fa;
}
&.unread {
background-color: #f0f9ff;
.title {
font-weight: 500;
}
}
.content {
flex: 1;
min-width: 0;
.title {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.message {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 12px;
color: #c0c4cc;
}
}
}
}
.notification-footer {
padding: 12px 16px;
border-top: 1px solid #e4e7ed;
text-align: center;
.view-all {
color: var(--el-color-primary);
text-decoration: none;
font-size: 14px;
&:hover {
opacity: 0.8;
}
}
}
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div class="search-component">
<el-popover
placement="bottom"
:width="600"
trigger="click"
:visible="visible"
@update:visible="val => visible = val"
>
<template #reference>
<el-input
v-model="searchQuery"
placeholder="搜索商品、秒杀活动..."
class="search-input"
@keyup.enter="handleQuickSearch"
@focus="handleFocus"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<el-button
v-if="searchQuery"
text
circle
size="small"
@click.stop="clearSearch"
>
<el-icon><Close /></el-icon>
</el-button>
</template>
</el-input>
</template>
<div class="search-panel">
<!-- 搜索历史 -->
<div v-if="!searchQuery && searchHistory.length > 0" class="search-section">
<div class="section-header">
<span class="title">搜索历史</span>
<el-button text size="small" @click="clearHistory">
清空
</el-button>
</div>
<div class="tag-list">
<el-tag
v-for="item in searchHistory"
:key="item"
closable
@click="selectHistory(item)"
@close="removeHistory(item)"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 热门搜索 -->
<div v-if="!searchQuery" class="search-section">
<div class="section-header">
<span class="title">热门搜索</span>
</div>
<div class="tag-list">
<el-tag
v-for="item in hotSearches"
:key="item"
@click="selectHot(item)"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 搜索建议 -->
<div v-if="searchQuery && suggestions.length > 0" class="search-suggestions">
<div class="section-header">
<span class="title">搜索建议</span>
</div>
<div class="suggestion-list">
<div
v-for="item in suggestions"
:key="item.id"
class="suggestion-item"
@click="selectSuggestion(item)"
>
<el-icon :size="16" class="mr-2">
<component :is="item.type === 'product' ? 'ShoppingBag' : 'Lightning'" />
</el-icon>
<div class="content">
<div class="name" v-html="highlightKeyword(item.name)"></div>
<div class="info">
<span class="type">{{ item.type === 'product' ? '商品' : '秒杀' }}</span>
<span class="price">¥{{ item.price }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 高级搜索 -->
<div class="advanced-search">
<el-collapse v-model="activeCollapse">
<el-collapse-item name="advanced">
<template #title>
<span class="text-sm text-blue-500">
<el-icon><Setting /></el-icon>
高级搜索
</span>
</template>
<div class="advanced-form">
<el-form :model="advancedForm" label-width="80px" size="small">
<el-form-item label="商品分类">
<el-select v-model="advancedForm.category" placeholder="选择分类">
<el-option label="全部分类" value="" />
<el-option label="电子产品" value="electronics" />
<el-option label="服装鞋包" value="fashion" />
<el-option label="食品饮料" value="food" />
<el-option label="图书音像" value="books" />
</el-select>
</el-form-item>
<el-form-item label="价格区间">
<div class="flex gap-2">
<el-input-number
v-model="advancedForm.minPrice"
:min="0"
placeholder="最低价"
/>
<span>-</span>
<el-input-number
v-model="advancedForm.maxPrice"
:min="0"
placeholder="最高价"
/>
</div>
</el-form-item>
<el-form-item label="搜索类型">
<el-checkbox-group v-model="advancedForm.types">
<el-checkbox label="product">商品</el-checkbox>
<el-checkbox label="flashsale">秒杀</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleAdvancedSearch">
搜索
</el-button>
<el-button @click="resetAdvanced">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { debounce } from 'lodash-es'
const router = useRouter()
const visible = ref(false)
const searchQuery = ref('')
const activeCollapse = ref<string[]>([])
// 搜索历史
const searchHistory = ref<string[]>([])
// 热门搜索
const hotSearches = ref([
'iPhone 15',
'MacBook Pro',
'秒杀活动',
'AirPods',
'限时特价',
'新品上市'
])
// 搜索建议
const suggestions = ref<any[]>([])
// 高级搜索表单
const advancedForm = reactive({
category: '',
minPrice: undefined as number | undefined,
maxPrice: undefined as number | undefined,
types: ['product', 'flashsale']
})
// 从localStorage加载搜索历史
const loadSearchHistory = () => {
const history = localStorage.getItem('searchHistory')
if (history) {
searchHistory.value = JSON.parse(history)
}
}
// 保存搜索历史
const saveSearchHistory = (keyword: string) => {
if (!keyword.trim()) return
// 去重并限制数量
const history = searchHistory.value.filter(item => item !== keyword)
history.unshift(keyword)
searchHistory.value = history.slice(0, 10)
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
}
// 清空搜索历史
const clearHistory = () => {
searchHistory.value = []
localStorage.removeItem('searchHistory')
}
// 移除单个历史
const removeHistory = (item: string) => {
searchHistory.value = searchHistory.value.filter(h => h !== item)
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
}
// 选择历史记录
const selectHistory = (item: string) => {
searchQuery.value = item
handleQuickSearch()
}
// 选择热门搜索
const selectHot = (item: string) => {
searchQuery.value = item
handleQuickSearch()
}
// 选择搜索建议
const selectSuggestion = (item: any) => {
if (item.type === 'product') {
router.push(`/product/${item.id}`)
} else {
router.push(`/flashsale/${item.id}`)
}
visible.value = false
saveSearchHistory(item.name)
}
// 高亮关键词
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>')
}
// 获取搜索建议
const fetchSuggestions = debounce(async () => {
if (!searchQuery.value.trim()) {
suggestions.value = []
return
}
// 模拟搜索建议
suggestions.value = [
{
id: 1,
type: 'product',
name: `${searchQuery.value} Pro Max`,
price: 8999
},
{
id: 2,
type: 'product',
name: `${searchQuery.value} 标准版`,
price: 5999
},
{
id: 3,
type: 'flashsale',
name: `${searchQuery.value} 限时秒杀`,
price: 4999
}
]
}, 300)
// 快速搜索
const handleQuickSearch = () => {
if (!searchQuery.value.trim()) return
saveSearchHistory(searchQuery.value)
router.push({
path: '/products',
query: { keyword: searchQuery.value }
})
visible.value = false
}
// 高级搜索
const handleAdvancedSearch = () => {
const params: any = {
keyword: searchQuery.value
}
if (advancedForm.category) params.category = advancedForm.category
if (advancedForm.minPrice !== undefined) params.minPrice = advancedForm.minPrice
if (advancedForm.maxPrice !== undefined) params.maxPrice = advancedForm.maxPrice
router.push({
path: '/products',
query: params
})
visible.value = false
saveSearchHistory(searchQuery.value)
}
// 重置高级搜索
const resetAdvanced = () => {
advancedForm.category = ''
advancedForm.minPrice = undefined
advancedForm.maxPrice = undefined
advancedForm.types = ['product', 'flashsale']
}
// 清空搜索
const clearSearch = () => {
searchQuery.value = ''
suggestions.value = []
}
// 处理焦点
const handleFocus = () => {
visible.value = true
}
// 监听搜索输入
watch(searchQuery, () => {
fetchSuggestions()
})
onMounted(() => {
loadSearchHistory()
})
</script>
<style scoped lang="scss">
.search-component {
.search-input {
width: 300px;
@media (max-width: 768px) {
width: 200px;
}
}
}
.search-panel {
.search-section {
margin-bottom: 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.title {
font-size: 14px;
font-weight: 500;
color: #303133;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.el-tag {
cursor: pointer;
&:hover {
background-color: var(--el-color-primary-light-9);
}
}
}
}
.search-suggestions {
.suggestion-list {
.suggestion-item {
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: #f5f7fa;
}
.content {
flex: 1;
.name {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.info {
display: flex;
gap: 10px;
font-size: 12px;
color: #909399;
.type {
padding: 0 6px;
background-color: #f0f0f0;
border-radius: 2px;
}
.price {
color: #f56c6c;
font-weight: 500;
}
}
}
}
}
}
.advanced-search {
margin-top: 20px;
border-top: 1px solid #e4e7ed;
padding-top: 10px;
.advanced-form {
padding: 10px 0;
}
}
}
</style>

View File

@@ -0,0 +1,219 @@
import { ref, onUnmounted } from 'vue'
import { ElNotification } from 'element-plus'
import { useUserStore } from '@/stores/user'
export interface WebSocketMessage {
type: 'FLASH_SALE_START' | 'FLASH_SALE_END' | 'ORDER_STATUS' | 'STOCK_UPDATE' | 'SYSTEM_NOTICE'
data: any
timestamp: number
}
class WebSocketService {
private ws: WebSocket | null = null
private reconnectTimer: number | null = null
private heartbeatTimer: number | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private listeners: Map<string, Set<Function>> = new Map()
constructor() {
this.connect()
}
private connect() {
const userStore = useUserStore()
if (!userStore.token) {
console.log('WebSocket: 用户未登录,跳过连接')
return
}
const wsUrl = `${import.meta.env.VITE_WS_URL}?token=${userStore.token}`
try {
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket 连接成功')
this.reconnectAttempts = 0
this.startHeartbeat()
// 发送认证信息
this.send({
type: 'AUTH',
token: userStore.token
})
}
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
this.handleMessage(message)
} catch (error) {
console.error('WebSocket 消息解析失败:', error)
}
}
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
}
this.ws.onclose = () => {
console.log('WebSocket 连接关闭')
this.stopHeartbeat()
this.attemptReconnect()
}
} catch (error) {
console.error('WebSocket 连接失败:', error)
this.attemptReconnect()
}
}
private handleMessage(message: WebSocketMessage) {
// 触发消息监听器
const listeners = this.listeners.get(message.type)
if (listeners) {
listeners.forEach(listener => listener(message.data))
}
// 显示通知
switch (message.type) {
case 'FLASH_SALE_START':
ElNotification({
title: '秒杀开始',
message: `${message.data.productName} 秒杀活动已开始!`,
type: 'success',
duration: 5000
})
break
case 'FLASH_SALE_END':
ElNotification({
title: '秒杀结束',
message: `${message.data.productName} 秒杀活动已结束`,
type: 'info',
duration: 3000
})
break
case 'ORDER_STATUS':
ElNotification({
title: '订单状态更新',
message: message.data.message,
type: 'info',
duration: 3000
})
break
case 'STOCK_UPDATE':
// 库存更新,不显示通知,只触发监听器
break
case 'SYSTEM_NOTICE':
ElNotification({
title: '系统通知',
message: message.data.content,
type: message.data.level || 'info',
duration: 5000
})
break
}
}
private startHeartbeat() {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'PING' }))
}
}, 30000) // 30秒心跳
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket 重连失败,已达最大重试次数')
return
}
this.reconnectAttempts++
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
console.log(`WebSocket 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
this.reconnectTimer = window.setTimeout(() => {
this.connect()
}, delay)
}
public send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
} else {
console.warn('WebSocket 未连接,无法发送消息')
}
}
public on(type: string, callback: Function) {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set())
}
this.listeners.get(type)!.add(callback)
}
public off(type: string, callback: Function) {
const listeners = this.listeners.get(type)
if (listeners) {
listeners.delete(callback)
}
}
public disconnect() {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.ws) {
this.ws.close()
this.ws = null
}
}
}
// 单例实例
let wsService: WebSocketService | null = null
export function useWebSocket() {
if (!wsService) {
wsService = new WebSocketService()
}
const subscribe = (type: string, callback: Function) => {
wsService?.on(type, callback)
}
const unsubscribe = (type: string, callback: Function) => {
wsService?.off(type, callback)
}
const send = (data: any) => {
wsService?.send(data)
}
onUnmounted(() => {
// 组件卸载时不断开连接,保持全局连接
})
return {
subscribe,
unsubscribe,
send
}
}

View File

@@ -0,0 +1,306 @@
<template>
<div class="admin-layout">
<el-container>
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '200px'" class="admin-sidebar">
<div class="logo-container">
<el-icon :size="24" class="logo-icon">
<Lightning />
</el-icon>
<transition name="fade">
<span v-if="!isCollapse" class="logo-text">管理后台</span>
</transition>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:collapse-transition="false"
router
>
<el-menu-item index="/admin">
<el-icon><DataLine /></el-icon>
<template #title>仪表盘</template>
</el-menu-item>
<el-menu-item index="/admin/products">
<el-icon><ShoppingBag /></el-icon>
<template #title>商品管理</template>
</el-menu-item>
<el-menu-item index="/admin/flashsales">
<el-icon><Lightning /></el-icon>
<template #title>秒杀管理</template>
</el-menu-item>
<el-menu-item index="/admin/orders">
<el-icon><List /></el-icon>
<template #title>订单管理</template>
</el-menu-item>
<el-menu-item index="/admin/users">
<el-icon><User /></el-icon>
<template #title>用户管理</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部导航 -->
<el-header class="admin-header">
<div class="header-left">
<el-icon
class="collapse-btn"
:size="20"
@click="isCollapse = !isCollapse"
>
<component :is="isCollapse ? 'Expand' : 'Fold'" />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/admin' }">
管理后台
</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentPageTitle">
{{ currentPageTitle }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 全屏 -->
<el-icon class="header-icon" @click="toggleFullscreen">
<FullScreen />
</el-icon>
<!-- 刷新 -->
<el-icon class="header-icon" @click="handleRefresh">
<Refresh />
</el-icon>
<!-- 用户信息 -->
<el-dropdown trigger="click">
<div class="user-info">
<el-avatar :size="32" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
</el-avatar>
<span class="username">{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/')">
<el-icon><HomeFilled /></el-icon>
返回前台
</el-dropdown-item>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="admin-main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const activeMenu = computed(() => route.path)
// 获取当前页面标题
const currentPageTitle = computed(() => {
const titles: Record<string, string> = {
'/admin': '',
'/admin/products': '商品管理',
'/admin/flashsales': '秒杀管理',
'/admin/orders': '订单管理',
'/admin/users': '用户管理',
}
return titles[route.path] || ''
})
// 全屏切换
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
document.exitFullscreen()
}
}
// 刷新页面
const handleRefresh = () => {
window.location.reload()
}
// 退出登录
const handleLogout = async () => {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
userStore.logout()
}
</script>
<style scoped lang="scss">
.admin-layout {
height: 100vh;
.el-container {
height: 100%;
}
}
.admin-sidebar {
background-color: #001529;
transition: width 0.3s;
.logo-container {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: white;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.logo-icon {
color: #ef4444;
}
.logo-text {
font-size: 16px;
font-weight: bold;
white-space: nowrap;
}
}
.el-menu {
border-right: none;
background-color: #001529;
:deep(.el-menu-item) {
color: rgba(255, 255, 255, 0.65);
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&.is-active {
color: white;
background-color: #1890ff !important;
}
}
}
}
.admin-header {
background-color: white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.header-left {
display: flex;
align-items: center;
gap: 20px;
.collapse-btn {
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
.header-icon {
cursor: pointer;
font-size: 18px;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.username {
font-size: 14px;
}
}
}
}
.admin-main {
background-color: #f0f2f5;
padding: 20px;
}
// 动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="main-layout">
<AppHeader />
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<AppFooter />
</div>
</template>
<script setup lang="ts">
import AppHeader from '@/components/common/AppHeader.vue'
import AppFooter from '@/components/common/AppFooter.vue'
</script>
<style scoped lang="scss">
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding-top: 60px; // header高度
background-color: #f5f5f5;
}
// 路由切换动画
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,27 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
// 全局样式
import 'element-plus/dist/index.css'
import './styles/index.scss'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@@ -0,0 +1,382 @@
<template>
<div class="admin-dashboard">
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-icon bg-blue-100">
<el-icon :size="24" class="text-blue-500"><User /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalUsers }}</div>
<div class="stat-label">用户总数</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
12% 较昨日
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-green-100">
<el-icon :size="24" class="text-green-500"><ShoppingBag /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalProducts }}</div>
<div class="stat-label">商品总数</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
5% 较昨日
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-orange-100">
<el-icon :size="24" class="text-orange-500"><List /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalOrders }}</div>
<div class="stat-label">订单总数</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
23% 较昨日
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-red-100">
<el-icon :size="24" class="text-red-500"><Coin /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">¥{{ statistics.totalSales.toFixed(2) }}</div>
<div class="stat-label">销售总额</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
18% 较昨日
</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 销售趋势 -->
<div class="chart-card">
<div class="chart-header">
<h3 class="chart-title">销售趋势</h3>
<el-radio-group v-model="salesPeriod" size="small">
<el-radio-button label="week">本周</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
</el-radio-group>
</div>
<div ref="salesChartRef" class="chart-container"></div>
</div>
<!-- 商品分类分布 -->
<div class="chart-card">
<div class="chart-header">
<h3 class="chart-title">商品分类分布</h3>
</div>
<div ref="categoryChartRef" class="chart-container"></div>
</div>
</div>
<!-- 数据表格 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 最新订单 -->
<div class="table-card">
<div class="table-header">
<h3 class="table-title">最新订单</h3>
<el-button text type="primary" @click="router.push('/admin/orders')">
查看全部 <el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<el-table :data="recentOrders" stripe>
<el-table-column prop="orderNo" label="订单号" width="120" />
<el-table-column prop="username" label="用户" />
<el-table-column prop="totalAmount" label="金额">
<template #default="{ row }">
¥{{ row.totalAmount }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getOrderStatusType(row.status)" size="small">
{{ getOrderStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 热门商品 -->
<div class="table-card">
<div class="table-header">
<h3 class="table-title">热门商品</h3>
<el-button text type="primary" @click="router.push('/admin/products')">
查看全部 <el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<el-table :data="hotProducts" stripe>
<el-table-column prop="name" label="商品名称" />
<el-table-column prop="price" label="价格">
<template #default="{ row }">
¥{{ row.price }}
</template>
</el-table-column>
<el-table-column prop="sales" label="销量" />
<el-table-column prop="stock" label="库存">
<template #default="{ row }">
<el-tag :type="row.stock > 10 ? 'success' : 'warning'" size="small">
{{ row.stock }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
const router = useRouter()
// 统计数据
const statistics = reactive({
totalUsers: 5234,
totalProducts: 128,
totalOrders: 1893,
totalSales: 285670.50,
todayOrders: 67,
todaySales: 12450.00,
activeFlashSales: 5,
onlineUsers: 342
})
// 图表相关
const salesPeriod = ref('week')
const salesChartRef = ref<HTMLElement>()
const categoryChartRef = ref<HTMLElement>()
let salesChart: echarts.ECharts | null = null
let categoryChart: echarts.ECharts | null = null
// 最新订单
const recentOrders = ref([
{ id: 1, orderNo: 'ORD2024001', username: 'user1', totalAmount: 299.00, status: 'PAID' },
{ id: 2, orderNo: 'ORD2024002', username: 'user2', totalAmount: 599.00, status: 'SHIPPED' },
{ id: 3, orderNo: 'ORD2024003', username: 'user3', totalAmount: 199.00, status: 'PENDING' },
{ id: 4, orderNo: 'ORD2024004', username: 'user4', totalAmount: 899.00, status: 'COMPLETED' },
{ id: 5, orderNo: 'ORD2024005', username: 'user5', totalAmount: 399.00, status: 'PAID' },
])
// 热门商品
const hotProducts = ref([
{ id: 1, name: 'iPhone 15 Pro', price: 8999, sales: 234, stock: 45 },
{ id: 2, name: 'MacBook Pro', price: 12999, sales: 156, stock: 23 },
{ id: 3, name: 'AirPods Pro', price: 1999, sales: 567, stock: 89 },
{ id: 4, name: 'iPad Pro', price: 6999, sales: 123, stock: 8 },
{ id: 5, name: 'Apple Watch', price: 2999, sales: 345, stock: 67 },
])
// 获取订单状态类型
const getOrderStatusType = (status: string) => {
const map: Record<string, string> = {
'PENDING': 'warning',
'PAID': 'primary',
'SHIPPED': 'primary',
'COMPLETED': 'success',
'CANCELLED': 'info'
}
return map[status] || 'info'
}
// 获取订单状态文本
const getOrderStatusText = (status: string) => {
const map: Record<string, string> = {
'PENDING': '待付款',
'PAID': '待发货',
'SHIPPED': '待收货',
'COMPLETED': '已完成',
'CANCELLED': '已取消'
}
return map[status] || status
}
// 初始化销售趋势图表
const initSalesChart = () => {
if (!salesChartRef.value) return
salesChart = echarts.init(salesChartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '¥{value}'
}
},
series: [
{
name: '销售额',
type: 'line',
smooth: true,
data: [12000, 15000, 13000, 18000, 22000, 25000, 20000],
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
])
},
itemStyle: {
color: '#3b82f6'
}
}
]
}
salesChart.setOption(option)
}
// 初始化分类分布图表
const initCategoryChart = () => {
if (!categoryChartRef.value) return
categoryChart = echarts.init(categoryChartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center'
},
series: [
{
name: '商品分类',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: 35, name: '电子产品' },
{ value: 28, name: '服装鞋包' },
{ value: 20, name: '食品饮料' },
{ value: 10, name: '图书音像' },
{ value: 7, name: '其他' }
]
}
]
}
categoryChart.setOption(option)
}
// 窗口大小变化处理
const handleResize = () => {
salesChart?.resize()
categoryChart?.resize()
}
onMounted(() => {
nextTick(() => {
initSalesChart()
initCategoryChart()
})
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
salesChart?.dispose()
categoryChart?.dispose()
})
</script>
<style scoped lang="scss">
.admin-dashboard {
.stat-card {
@apply bg-white rounded-lg p-6 shadow-sm flex items-center gap-4;
.stat-icon {
@apply w-12 h-12 rounded-lg flex items-center justify-center;
}
.stat-content {
@apply flex-1;
.stat-value {
@apply text-2xl font-bold text-gray-800;
}
.stat-label {
@apply text-sm text-gray-500 mt-1;
}
.stat-trend {
@apply text-sm mt-2 flex items-center gap-1;
}
}
}
.chart-card,
.table-card {
@apply bg-white rounded-lg shadow-sm;
.chart-header,
.table-header {
@apply p-4 border-b flex justify-between items-center;
.chart-title,
.table-title {
@apply text-lg font-semibold;
}
}
.chart-container {
@apply p-4;
height: 300px;
}
}
.table-card {
:deep(.el-table) {
border-radius: 0 0 8px 8px;
}
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="admin-flashsales">
<div class="page-header">
<h2 class="page-title">秒杀活动管理</h2>
<el-button type="primary">
<el-icon class="mr-1"><Plus /></el-icon>
创建秒杀
</el-button>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="productName" label="商品名称" />
<el-table-column prop="flashPrice" label="秒杀价" />
<el-table-column prop="flashStock" label="秒杀库存" />
<el-table-column prop="startTime" label="开始时间" />
<el-table-column prop="endTime" label="结束时间" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="200">
<template #default>
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-flashsales {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="admin-orders">
<div class="page-header">
<h2 class="page-title">订单管理</h2>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="orderNo" label="订单号" />
<el-table-column prop="username" label="用户" />
<el-table-column prop="totalAmount" label="金额" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createdAt" label="创建时间" />
<el-table-column label="操作" width="150">
<template #default>
<el-button text type="primary" size="small">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-orders {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="admin-products">
<div class="page-header">
<h2 class="page-title">商品管理</h2>
<el-button type="primary">
<el-icon class="mr-1"><Plus /></el-icon>
添加商品
</el-button>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="商品名称" />
<el-table-column prop="price" label="价格" />
<el-table-column prop="stock" label="库存" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="200">
<template #default>
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-products {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="admin-users">
<div class="page-header">
<h2 class="page-title">用户管理</h2>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createdAt" label="注册时间" />
<el-table-column label="操作" width="150">
<template #default>
<el-button text type="primary" size="small">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-users {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<div class="cart-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center">
<el-icon class="text-blue-500 mr-2"><ShoppingCart /></el-icon>
购物车
</h1>
</div>
<div v-if="cartStore.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="cartStore.items.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="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧购物车列表 -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm">
<!-- 全选栏 -->
<div class="border-b px-6 py-4 flex justify-between items-center">
<el-checkbox
v-model="selectAll"
:indeterminate="indeterminate"
@change="handleSelectAll"
>
全选{{ cartStore.selectedCount }}/{{ cartStore.itemCount }}
</el-checkbox>
<el-button
text
type="danger"
:disabled="cartStore.selectedCount === 0"
@click="handleBatchRemove"
>
<el-icon class="mr-1"><Delete /></el-icon>
删除选中
</el-button>
</div>
<!-- 商品列表 -->
<div class="divide-y">
<div
v-for="item in cartStore.items"
:key="item.id"
class="p-6 hover:bg-gray-50 transition-colors"
>
<div class="flex gap-4">
<!-- 选择框 -->
<el-checkbox
v-model="item.selected"
@change="handleSelectItem(item.id)"
/>
<!-- 商品图片 -->
<img
:src="item.productImage || '/default-product.png'"
:alt="item.productName"
class="w-24 h-24 object-cover rounded"
@error="handleImageError"
>
<!-- 商品信息 -->
<div class="flex-1">
<h3 class="font-semibold mb-2">
<router-link
:to="`/product/${item.productId}`"
class="hover:text-primary-500"
>
{{ item.productName }}
</router-link>
</h3>
<div class="flex items-center justify-between">
<div>
<span class="text-red-500 font-bold text-lg">
¥{{ item.price }}
</span>
<span class="ml-2 text-sm text-gray-400">
库存: {{ item.stock }}
</span>
</div>
<!-- 数量选择 -->
<div class="flex items-center gap-2">
<el-input-number
v-model="item.quantity"
:min="1"
:max="item.stock"
size="small"
@change="handleQuantityChange(item.id, item.quantity)"
/>
<el-button
text
type="danger"
size="small"
@click="handleRemoveItem(item.id)"
>
删除
</el-button>
</div>
</div>
<!-- 小计 -->
<div class="mt-2 text-right">
<span class="text-sm text-gray-500">小计</span>
<span class="text-lg font-semibold text-red-500">
¥{{ (item.price * item.quantity).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="border-t px-6 py-4 flex justify-between items-center">
<el-button text @click="handleClearCart">
<el-icon class="mr-1"><Delete /></el-icon>
清空购物车
</el-button>
<el-button text type="primary" @click="router.push('/products')">
继续购物
</el-button>
</div>
</div>
</div>
<!-- 右侧结算信息 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6 sticky top-20">
<h3 class="text-lg font-semibold mb-4">订单结算</h3>
<!-- 费用明细 -->
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span class="text-gray-600">商品总额</span>
<span>¥{{ cartStore.totalPrice.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">运费</span>
<span class="text-green-500">免运费</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">优惠</span>
<span class="text-red-500">-¥0.00</span>
</div>
</div>
<!-- 总计 -->
<div class="border-t pt-4 mb-6">
<div class="flex justify-between items-center">
<span class="text-lg">应付总额</span>
<span class="text-2xl font-bold text-red-500">
¥{{ cartStore.totalPrice.toFixed(2) }}
</span>
</div>
<p class="text-sm text-gray-500 mt-1">
已选 {{ cartStore.selectedCount }} {{ cartStore.totalQuantity }}
</p>
</div>
<!-- 结算按钮 -->
<el-button
type="danger"
size="large"
class="w-full"
:disabled="cartStore.selectedCount === 0"
@click="handleCheckout"
>
去结算{{ cartStore.selectedCount }}
</el-button>
<!-- 推荐商品 -->
<div class="mt-6 pt-6 border-t">
<h4 class="font-semibold mb-3">为你推荐</h4>
<div class="space-y-3">
<div
v-for="item in recommendProducts"
:key="item.id"
class="flex gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded"
@click="router.push(`/product/${item.id}`)"
>
<img
:src="item.imageUrl"
:alt="item.name"
class="w-16 h-16 object-cover rounded"
>
<div class="flex-1">
<p class="text-sm line-clamp-2">{{ item.name }}</p>
<p class="text-red-500 font-semibold">¥{{ item.price }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useCartStore } from '@/stores/cart'
import { productApi } from '@/api/modules/product'
import type { Product } from '@/types/api'
const router = useRouter()
const cartStore = useCartStore()
const recommendProducts = ref<Product[]>([])
// 全选状态
const selectAll = computed({
get: () => cartStore.isAllSelected,
set: (value) => {
if (value !== cartStore.isAllSelected) {
cartStore.toggleSelectAll()
}
}
})
const indeterminate = computed(() => {
return cartStore.selectedCount > 0 && cartStore.selectedCount < cartStore.itemCount
})
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 全选/取消全选
const handleSelectAll = () => {
cartStore.toggleSelectAll()
}
// 选择单个商品
const handleSelectItem = (itemId: string) => {
cartStore.toggleSelect(itemId)
}
// 修改数量
const handleQuantityChange = async (itemId: string, quantity: number) => {
await cartStore.updateQuantity(itemId, quantity)
}
// 删除单个商品
const handleRemoveItem = async (itemId: string) => {
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cartStore.removeItem(itemId)
}
// 批量删除
const handleBatchRemove = async () => {
if (cartStore.selectedCount === 0) return
await ElMessageBox.confirm(
`确定要删除选中的 ${cartStore.selectedCount} 件商品吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await cartStore.removeSelected()
}
// 清空购物车
const handleClearCart = async () => {
await ElMessageBox.confirm('确定要清空购物车吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cartStore.clearCart()
}
// 去结算
const handleCheckout = async () => {
if (cartStore.selectedCount === 0) {
ElMessage.warning('请选择要结算的商品')
return
}
// TODO: 跳转到订单确认页
ElMessage.info('功能开发中...')
}
// 加载推荐商品
const loadRecommendProducts = async () => {
try {
const res = await productApi.getHot(3)
if (res.success) {
recommendProducts.value = res.data
}
} catch (error) {
console.error('加载推荐商品失败:', error)
}
}
onMounted(() => {
cartStore.fetchCart()
loadRecommendProducts()
})
</script>
<style scoped lang="scss">
.cart-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="error-404">
<div class="container mx-auto px-4 py-16 text-center">
<el-icon :size="120" class="text-gray-300 mb-8">
<Warning />
</el-icon>
<h1 class="text-6xl font-bold text-gray-800 mb-4">404</h1>
<p class="text-2xl text-gray-600 mb-8">页面未找到</p>
<p class="text-gray-500 mb-8">抱歉您访问的页面不存在或已被移除</p>
<div class="space-x-4">
<el-button type="primary" size="large" @click="router.push('/')">
<el-icon class="mr-2"><HomeFilled /></el-icon>
返回首页
</el-button>
<el-button size="large" @click="router.back()">
<el-icon class="mr-2"><Back /></el-icon>
返回上一页
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<style scoped lang="scss">
.error-404 {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<div class="flashsale-detail-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 :to="{ path: '/flashsale' }">秒杀活动</el-breadcrumb-item>
<el-breadcrumb-item>{{ flashSale?.productName || '详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<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="!flashSale" class="text-center py-12">
<el-empty description="秒杀活动不存在" />
<el-button type="primary" @click="router.push('/flashsale')">
返回秒杀列表
</el-button>
</div>
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
<!-- 左侧商品图片 -->
<div>
<div class="relative">
<img
:src="flashSale.productImageUrl || '/default-product.png'"
:alt="flashSale.productName"
class="w-full rounded-lg"
@error="handleImageError"
>
<!-- 状态标签 -->
<div class="absolute top-4 left-4">
<el-tag :type="statusType" size="large" effect="dark">
<el-icon class="mr-1"><Lightning /></el-icon>
{{ statusText }}
</el-tag>
</div>
</div>
</div>
<!-- 右侧商品信息 -->
<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="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="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>
</div>
<!-- 活动时间 -->
<div class="text-sm text-gray-600 mt-4">
<p>开始时间{{ formatTime(flashSale.startTime) }}</p>
<p>结束时间{{ formatTime(flashSale.endTime) }}</p>
</div>
</div>
<!-- 库存信息 -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">库存情况</span>
<span class="text-sm text-gray-500">
剩余 {{ flashSale.remainingStock }} / {{ flashSale.flashStock }}
</span>
</div>
<el-progress
:percentage="stockPercent"
:stroke-width="10"
:color="progressColor"
/>
</div>
<!-- 倒计时 -->
<div v-if="flashSale.status === 'ACTIVE'" class="mb-6">
<div class="text-center p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600 mb-2">距离结束还有</p>
<CountDown
:end-time="endTime"
@finish="handleFinish"
/>
</div>
</div>
<!-- 限购信息 -->
<div class="mb-6 p-4 bg-blue-50 rounded-lg">
<div class="flex items-center text-blue-700">
<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-icon class="mr-2"><Lightning /></el-icon>
{{ buttonText }}
</el-button>
<div class="flex gap-4">
<el-button size="large" class="flex-1" @click="handleViewProduct">
查看商品详情
</el-button>
<el-button size="large" class="flex-1" @click="handleShare">
<el-icon class="mr-1"><Share /></el-icon>
分享
</el-button>
</div>
</div>
<!-- 活动说明 -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 class="font-semibold mb-2">活动说明</h3>
<div class="text-sm text-gray-600 space-y-1">
<p> 秒杀商品数量有限先到先得</p>
<p> 每个用户限购{{ flashSale.limitPerUser }}</p>
<p> 秒杀成功后请在30分钟内完成支付</p>
<p> 商品售出后不支持退换</p>
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div v-if="flashSale.description" class="border-t px-8 py-6">
<h3 class="text-xl font-semibold mb-4">活动详情</h3>
<div class="text-gray-600" v-html="flashSale.description"></div>
</div>
</div>
</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 CountDown from '@/components/business/CountDown.vue'
import { flashsaleApi } from '@/api/modules/flashsale'
import { useUserStore } from '@/stores/user'
import type { FlashSale } from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const participating = ref(false)
const flashSale = ref<FlashSale | null>(null)
// 计算属性
const statusType = computed(() => {
if (!flashSale.value) return 'info'
switch (flashSale.value.status) {
case 'UPCOMING': return 'warning'
case 'ACTIVE': return 'danger'
case 'ENDED': return 'info'
default: return 'info'
}
})
const statusText = computed(() => {
if (!flashSale.value) return ''
switch (flashSale.value.status) {
case 'UPCOMING': return '即将开始'
case 'ACTIVE': return '秒杀进行中'
case 'ENDED': return '已结束'
default: return '未知'
}
})
const discountPercent = computed(() => {
if (!flashSale.value) return 0
return Math.round((1 - flashSale.value.flashPrice / flashSale.value.originalPrice) * 100)
})
const stockPercent = computed(() => {
if (!flashSale.value || flashSale.value.flashStock === 0) return 0
return Math.round(flashSale.value.remainingStock / flashSale.value.flashStock * 100)
})
const progressColor = computed(() => {
if (stockPercent.value > 50) return '#67c23a'
if (stockPercent.value > 20) return '#e6a23c'
return '#f56c6c'
})
const endTime = computed(() => {
if (!flashSale.value) return 0
return new Date(flashSale.value.endTime).getTime()
})
const canParticipate = computed(() => {
if (!flashSale.value) return false
return flashSale.value.status === 'ACTIVE' && flashSale.value.remainingStock > 0
})
const buttonText = computed(() => {
if (!flashSale.value) return '立即抢购'
if (flashSale.value.status === 'UPCOMING') return '即将开始'
if (flashSale.value.status === 'ENDED') return '已结束'
if (flashSale.value.remainingStock === 0) return '已售罄'
return '立即抢购'
})
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 加载秒杀详情
const loadFlashSaleDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await flashsaleApi.getDetail(id)
if (res.success) {
flashSale.value = res.data
}
} catch (error) {
console.error('加载秒杀详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
// 参与秒杀
const handleParticipate = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
if (!flashSale.value || !canParticipate.value) return
try {
await ElMessageBox.confirm(
`确定要抢购该商品吗?\n秒杀价¥${flashSale.value.flashPrice}`,
'确认抢购',
{
confirmButtonText: '确定抢购',
cancelButtonText: '再想想',
type: 'warning',
}
)
participating.value = true
const startTime = Date.now()
const res = await flashsaleApi.participate({
flashSaleId: flashSale.value.id,
quantity: 1,
timestamp: startTime
})
if (res.success) {
const duration = Date.now() - startTime
ElMessage.success(`抢购成功!耗时${duration}ms`)
// 跳转到订单页面
setTimeout(() => {
router.push('/orders')
}, 1500)
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '抢购失败')
}
} finally {
participating.value = false
// 重新加载详情
loadFlashSaleDetail()
}
}
// 查看商品详情
const handleViewProduct = () => {
if (flashSale.value) {
router.push(`/product/${flashSale.value.productId}`)
}
}
// 分享
const handleShare = () => {
ElMessage.success('分享链接已复制')
// 实际实现复制链接到剪贴板
const url = window.location.href
navigator.clipboard.writeText(url)
}
// 倒计时结束
const handleFinish = () => {
loadFlashSaleDetail()
}
onMounted(() => {
loadFlashSaleDetail()
})
</script>
<style scoped lang="scss">
.flashsale-detail-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="flashsale-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<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>
秒杀活动
</h1>
<p class="text-gray-600">限时抢购先到先得</p>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 状态筛选 -->
<el-radio-group v-model="filters.status" @change="loadFlashSales">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="UPCOMING">即将开始</el-radio-button>
<el-radio-button label="ACTIVE">进行中</el-radio-button>
<el-radio-button label="ENDED">已结束</el-radio-button>
</el-radio-group>
<!-- 排序 -->
<el-select
v-model="filters.sort"
placeholder="排序方式"
style="width: 150px"
@change="loadFlashSales"
>
<el-option label="开始时间" value="startTime" />
<el-option label="结束时间" value="endTime" />
<el-option label="价格从低到高" value="flashPrice" />
<el-option label="折扣力度" value="discount" />
</el-select>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
placeholder="搜索商品名称"
style="width: 200px"
clearable
@keyup.enter="loadFlashSales"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadFlashSales">
<Search />
</el-icon>
</template>
</el-input>
<!-- 刷新按钮 -->
<el-button @click="handleRefresh">
<el-icon class="mr-1"><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 统计信息 -->
<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-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-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-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-value">{{ statistics.success }}</div>
<div class="stat-label">抢购成功</div>
<el-icon :size="30" class="stat-icon"><SuccessFilled /></el-icon>
</div>
</div>
<!-- 秒杀活动列表 -->
<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="flashSales.length === 0" class="text-center py-12">
<el-empty description="暂无秒杀活动" />
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<FlashSaleCard
v-for="item in flashSales"
:key="item.id"
:data="item"
@participate="handleParticipate"
@refresh="loadFlashSales"
/>
</div>
<!-- 分页 -->
<div 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="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadFlashSales"
@current-change="loadFlashSales"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
import { flashsaleApi } from '@/api/modules/flashsale'
import { useUserStore } from '@/stores/user'
import type { FlashSale } from '@/types/api'
const router = useRouter()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const flashSales = ref<FlashSale[]>([])
// 筛选条件
const filters = reactive({
status: '',
sort: 'startTime',
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
size: 12,
total: 0
})
// 统计信息
const statistics = reactive({
upcoming: 0,
active: 0,
participated: 0,
success: 0
})
// 加载秒杀活动
const loadFlashSales = async () => {
loading.value = true
try {
const res = await flashsaleApi.getList({
...filters,
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)
} finally {
loading.value = false
}
}
// 更新统计信息
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 () => {
try {
const res = await flashsaleApi.getUserRecords()
if (res.success) {
statistics.participated = res.data.length
statistics.success = res.data.filter((item: any) => item.success).length
}
} catch (error) {
console.error('加载用户统计失败:', error)
}
}
// 参与秒杀
const handleParticipate = async (flashSaleId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 先检查资格
try {
const res = await flashsaleApi.checkEligibility(flashSaleId)
if (res.success && res.data.eligible) {
// 确认对话框
await ElMessageBox.confirm(
'确定要参与这个秒杀活动吗?',
'提示',
{
confirmButtonText: '立即抢购',
cancelButtonText: '取消',
type: 'warning',
}
)
// 跳转到详情页参与
router.push(`/flashsale/${flashSaleId}`)
} else {
ElMessage.warning(res.data.reason || '您暂时无法参与此活动')
}
} catch (error) {
// 用户取消或错误
}
}
// 刷新
const handleRefresh = () => {
loadFlashSales()
ElMessage.success('已刷新')
}
onMounted(() => {
loadFlashSales()
})
</script>
<style scoped lang="scss">
.flashsale-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.stat-card {
@apply relative overflow-hidden rounded-lg p-4 text-white;
.stat-value {
@apply text-2xl font-bold;
}
.stat-label {
@apply text-sm opacity-90 mt-1;
}
.stat-icon {
@apply absolute right-4 bottom-4 opacity-30;
}
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div class="home-page">
<!-- 轮播图 -->
<el-carousel height="400px" :interval="5000" arrow="hover">
<el-carousel-item v-for="item in banners" :key="item.id">
<div class="banner-content" :style="{ background: item.bgColor }">
<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">
<el-icon :size="40"><Lightning /></el-icon>
{{ item.title }}
</h1>
<p class="text-xl text-white mb-6">{{ item.subtitle }}</p>
<div class="space-x-4">
<el-button size="large" type="primary" @click="router.push(item.link)">
{{ item.buttonText }}
</el-button>
<el-button size="large" @click="router.push('/products')">
浏览商品
</el-button>
</div>
</div>
<div class="w-1/2 text-center">
<el-icon :size="200" class="text-white opacity-50">
<component :is="item.icon" />
</el-icon>
</div>
</div>
</div>
</div>
</el-carousel-item>
</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="text-red-500 mr-2"><Lightning /></el-icon>
正在秒杀
</h2>
<el-button text @click="router.push('/flashsale')">
查看全部
<el-icon class="ml-1"><ArrowRight /></el-icon>
</el-button>
</div>
<div v-if="loadingFlashSales" class="text-center py-8">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="activeFlashSales.length === 0" class="text-center py-8">
<el-empty description="暂无进行中的秒杀活动" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<FlashSaleCard
v-for="item in activeFlashSales"
:key="item.id"
:data="item"
@participate="handleParticipate"
/>
</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-orange-500 mr-2"><Star /></el-icon>
热门商品
</h2>
<el-button text @click="router.push('/products')">
查看全部
<el-icon class="ml-1"><ArrowRight /></el-icon>
</el-button>
</div>
<div v-if="loadingProducts" class="text-center py-8">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="hotProducts.length === 0" class="text-center py-8">
<el-empty description="暂无热门商品" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<ProductCard
v-for="item in hotProducts"
:key="item.id"
:data="item"
@add-to-cart="handleAddToCart"
/>
</div>
</section>
<!-- 系统特性 -->
<section class="mb-12">
<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>
<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>
<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>
<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"><Speedometer /></el-icon>
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
<p class="text-gray-600">多种限流策略防止恶意刷单</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
import ProductCard from '@/components/business/ProductCard.vue'
import { flashsaleApi } from '@/api/modules/flashsale'
import { productApi } from '@/api/modules/product'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import type { FlashSale, Product } from '@/types/api'
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
// 轮播图数据
const banners = [
{
id: 1,
title: '秒杀系统',
subtitle: '基于Redis集群构建的高并发秒杀系统',
buttonText: '立即抢购',
link: '/flashsale',
bgColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
icon: 'Lightning'
},
{
id: 2,
title: '防超卖机制',
subtitle: '采用分布式锁和Lua脚本确保数据一致性',
buttonText: '了解更多',
link: '/flashsale',
bgColor: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
icon: 'Lock'
},
{
id: 3,
title: '高性能缓存',
subtitle: 'Redis集群架构毫秒级响应',
buttonText: '查看商品',
link: '/products',
bgColor: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
icon: 'Speedometer'
}
]
// 数据状态
const loadingFlashSales = ref(false)
const loadingProducts = ref(false)
const activeFlashSales = ref<FlashSale[]>([])
const hotProducts = ref<Product[]>([])
// 加载秒杀活动
const loadFlashSales = async () => {
loadingFlashSales.value = true
try {
const res = await flashsaleApi.getActive(4)
if (res.success) {
activeFlashSales.value = res.data
}
} catch (error) {
console.error('加载秒杀活动失败:', error)
} finally {
loadingFlashSales.value = false
}
}
// 加载热门商品
const loadProducts = async () => {
loadingProducts.value = true
try {
const res = await productApi.getHot(8)
if (res.success) {
hotProducts.value = res.data
}
} catch (error) {
console.error('加载热门商品失败:', error)
} finally {
loadingProducts.value = false
}
}
// 参与秒杀
const handleParticipate = async (flashSaleId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 跳转到秒杀详情页
router.push(`/flashsale/${flashSaleId}`)
}
// 添加到购物车
const handleAddToCart = async (productId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
await cartStore.addToCart(productId)
}
onMounted(() => {
loadFlashSales()
loadProducts()
})
</script>
<style scoped lang="scss">
.home-page {
min-height: 100vh;
}
.banner-content {
height: 100%;
display: flex;
align-items: center;
}
.feature-card {
@apply bg-white p-6 rounded-lg shadow-md text-center hover:shadow-lg transition-shadow;
}
:deep(.el-carousel__item) {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="order-detail-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 :to="{ path: '/orders' }">我的订单</el-breadcrumb-item>
<el-breadcrumb-item>订单详情</el-breadcrumb-item>
</el-breadcrumb>
<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="!order" class="text-center py-12">
<el-empty description="订单不存在" />
<el-button type="primary" @click="router.push('/orders')">
返回订单列表
</el-button>
</div>
<div v-else>
<!-- 订单状态 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">订单状态</h2>
<el-tag :type="getStatusType(order.status)" size="large">
{{ getStatusText(order.status) }}
</el-tag>
</div>
<!-- 订单进度 -->
<el-steps :active="getActiveStep()" finish-status="success">
<el-step title="提交订单" :description="formatTime(order.createdAt)" />
<el-step title="支付订单" :description="order.paidAt ? formatTime(order.paidAt) : ''" />
<el-step title="商家发货" :description="order.shippedAt ? formatTime(order.shippedAt) : ''" />
<el-step title="确认收货" :description="order.completedAt ? formatTime(order.completedAt) : ''" />
</el-steps>
<!-- 操作按钮 -->
<div class="mt-6 flex gap-2">
<template v-if="order.status === 'PENDING'">
<el-button type="primary" @click="handlePay">立即付款</el-button>
<el-button @click="handleCancel">取消订单</el-button>
</template>
<template v-else-if="order.status === 'SHIPPED'">
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
</template>
<template v-else-if="order.status === 'COMPLETED'">
<el-button @click="handleReview">评价</el-button>
<el-button @click="handleRebuy">再次购买</el-button>
</template>
</div>
</div>
<!-- 收货信息 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">收货信息</h3>
<div v-if="order.address" class="text-sm space-y-2">
<div>
<span class="text-gray-500">收货人</span>
<span>{{ order.address.name }} {{ order.address.phone }}</span>
</div>
<div>
<span class="text-gray-500">收货地址</span>
<span>
{{ order.address.province }}
{{ order.address.city }}
{{ order.address.district }}
{{ order.address.address }}
</span>
</div>
</div>
<div v-else class="text-gray-500">
暂无收货信息
</div>
</div>
<!-- 商品信息 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">商品信息</h3>
<div class="space-y-4">
<div
v-for="item in order.items"
:key="item.id"
class="flex gap-4 pb-4 border-b last:border-0"
>
<img
:src="item.productImage || '/default-product.png'"
:alt="item.productName"
class="w-24 h-24 object-cover rounded"
@error="handleImageError"
>
<div class="flex-1">
<h4 class="font-semibold mb-2">{{ item.productName }}</h4>
<div class="text-sm text-gray-500">
单价¥{{ item.price }} × {{ item.quantity }}
</div>
</div>
<div class="text-right">
<div class="font-semibold text-lg">¥{{ item.subtotal }}</div>
</div>
</div>
</div>
<!-- 费用明细 -->
<div class="border-t pt-4 mt-4 space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">商品总额</span>
<span>¥{{ order.totalAmount }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">运费</span>
<span>¥0.00</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">优惠</span>
<span class="text-red-500">-¥{{ (order.totalAmount - order.paymentAmount).toFixed(2) }}</span>
</div>
<div class="flex justify-between pt-2 border-t">
<span class="font-semibold">实付金额</span>
<span class="text-xl font-bold text-red-500">¥{{ order.paymentAmount }}</span>
</div>
</div>
</div>
<!-- 订单信息 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-4">订单信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">订单编号</span>
<span>{{ order.orderNo }}</span>
<el-button text type="primary" size="small" @click="copyOrderNo">
复制
</el-button>
</div>
<div>
<span class="text-gray-500">创建时间</span>
<span>{{ formatTime(order.createdAt) }}</span>
</div>
<div v-if="order.paidAt">
<span class="text-gray-500">付款时间</span>
<span>{{ formatTime(order.paidAt) }}</span>
</div>
<div v-if="order.paymentMethod">
<span class="text-gray-500">支付方式</span>
<span>{{ getPaymentMethodText(order.paymentMethod) }}</span>
</div>
<div v-if="order.shippedAt">
<span class="text-gray-500">发货时间</span>
<span>{{ formatTime(order.shippedAt) }}</span>
</div>
<div v-if="order.completedAt">
<span class="text-gray-500">完成时间</span>
<span>{{ formatTime(order.completedAt) }}</span>
</div>
<div v-if="order.remark" class="md:col-span-2">
<span class="text-gray-500">订单备注</span>
<span>{{ order.remark }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderApi } from '@/api/modules/order'
import type { Order } from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const order = ref<Order | null>(null)
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 获取状态类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'PENDING': 'warning',
'PAID': 'primary',
'SHIPPED': 'primary',
'COMPLETED': 'success',
'CANCELLED': 'info',
'REFUNDED': 'danger'
}
return map[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'PENDING': '待付款',
'PAID': '待发货',
'SHIPPED': '待收货',
'COMPLETED': '已完成',
'CANCELLED': '已取消',
'REFUNDED': '已退款'
}
return map[status] || status
}
// 获取支付方式文本
const getPaymentMethodText = (method: string) => {
const map: Record<string, string> = {
'ONLINE': '在线支付',
'ALIPAY': '支付宝',
'WECHAT': '微信支付',
'CASH': '货到付款'
}
return map[method] || method
}
// 获取当前步骤
const getActiveStep = () => {
if (!order.value) return 0
switch (order.value.status) {
case 'PENDING': return 1
case 'PAID': return 2
case 'SHIPPED': return 3
case 'COMPLETED': return 4
case 'CANCELLED':
case 'REFUNDED': return -1
default: return 0
}
}
// 加载订单详情
const loadOrderDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await orderApi.getDetail(id)
if (res.success) {
order.value = res.data
}
} catch (error) {
console.error('加载订单详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
// 复制订单号
const copyOrderNo = () => {
if (order.value) {
navigator.clipboard.writeText(order.value.orderNo)
ElMessage.success('订单号已复制')
}
}
// 付款
const handlePay = async () => {
if (!order.value) return
await ElMessageBox.confirm(
`订单金额:¥${order.value.paymentAmount},确认付款?`,
'付款确认',
{
confirmButtonText: '确认付款',
cancelButtonText: '取消',
type: 'warning',
}
)
try {
await orderApi.pay(order.value.id, 'ONLINE')
ElMessage.success('付款成功')
loadOrderDetail()
} catch (error) {
console.error('付款失败:', error)
}
}
// 取消订单
const handleCancel = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.cancel(order.value.id)
ElMessage.success('订单已取消')
loadOrderDetail()
} catch (error) {
console.error('取消订单失败:', error)
}
}
// 确认收货
const handleConfirm = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.confirm(order.value.id)
ElMessage.success('已确认收货')
loadOrderDetail()
} catch (error) {
console.error('确认收货失败:', error)
}
}
// 评价
const handleReview = () => {
ElMessage.info('评价功能开发中...')
}
// 再次购买
const handleRebuy = () => {
ElMessage.success('商品已加入购物车')
router.push('/cart')
}
onMounted(() => {
loadOrderDetail()
})
</script>
<style scoped lang="scss">
.order-detail-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div class="orders-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center">
<el-icon class="text-blue-500 mr-2"><List /></el-icon>
我的订单
</h1>
</div>
<!-- 订单统计 -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
<div
v-for="stat in orderStats"
:key="stat.key"
class="bg-white rounded-lg p-4 text-center cursor-pointer hover:shadow-md transition-shadow"
@click="handleStatusFilter(stat.key)"
>
<el-icon :size="24" :class="stat.color" class="mb-2">
<component :is="stat.icon" />
</el-icon>
<div class="text-2xl font-bold">{{ stat.count }}</div>
<div class="text-sm text-gray-500">{{ stat.label }}</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 状态筛选 -->
<el-radio-group v-model="filters.status" @change="loadOrders">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="PENDING">待付款</el-radio-button>
<el-radio-button label="PAID">待发货</el-radio-button>
<el-radio-button label="SHIPPED">待收货</el-radio-button>
<el-radio-button label="COMPLETED">已完成</el-radio-button>
<el-radio-button label="CANCELLED">已取消</el-radio-button>
</el-radio-group>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
placeholder="搜索订单号或商品名称"
style="width: 250px"
clearable
@keyup.enter="loadOrders"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadOrders">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<!-- 订单列表 -->
<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="orders.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="order in orders"
:key="order.id"
class="bg-white rounded-lg shadow-sm overflow-hidden"
>
<!-- 订单头部 -->
<div class="bg-gray-50 px-6 py-3 flex justify-between items-center">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>订单号{{ order.orderNo }}</span>
<span>{{ formatTime(order.createdAt) }}</span>
</div>
<el-tag :type="getStatusType(order.status)">
{{ getStatusText(order.status) }}
</el-tag>
</div>
<!-- 订单商品 -->
<div class="p-6">
<div
v-for="item in order.items"
:key="item.id"
class="flex gap-4 mb-4 last:mb-0"
>
<img
:src="item.productImage || '/default-product.png'"
:alt="item.productName"
class="w-20 h-20 object-cover rounded"
@error="handleImageError"
>
<div class="flex-1">
<h4 class="font-semibold">{{ item.productName }}</h4>
<div class="text-sm text-gray-500 mt-1">
¥{{ item.price }} × {{ item.quantity }}
</div>
</div>
<div class="text-right">
<div class="font-semibold">¥{{ item.subtotal }}</div>
</div>
</div>
</div>
<!-- 订单底部 -->
<div class="border-t px-6 py-4 flex justify-between items-center">
<div>
<span class="text-sm text-gray-500">实付金额</span>
<span class="text-xl font-bold text-red-500">
¥{{ order.paymentAmount || order.totalAmount }}
</span>
</div>
<div class="space-x-2">
<el-button
text
type="primary"
@click="handleViewDetail(order.id)"
>
查看详情
</el-button>
<!-- 根据状态显示不同操作 -->
<template v-if="order.status === 'PENDING'">
<el-button type="primary" size="small" @click="handlePay(order)">
立即付款
</el-button>
<el-button size="small" @click="handleCancel(order)">
取消订单
</el-button>
</template>
<template v-else-if="order.status === 'SHIPPED'">
<el-button type="primary" size="small" @click="handleConfirm(order)">
确认收货
</el-button>
</template>
<template v-else-if="order.status === 'COMPLETED'">
<el-button size="small" @click="handleReview(order)">
评价
</el-button>
<el-button size="small" @click="handleRebuy(order)">
再次购买
</el-button>
</template>
<template v-else-if="order.status === 'CANCELLED' || order.status === 'REFUNDED'">
<el-button text type="danger" size="small" @click="handleDelete(order)">
删除订单
</el-button>
</template>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<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, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadOrders"
@current-change="loadOrders"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderApi } from '@/api/modules/order'
import type { Order } from '@/types/api'
import dayjs from 'dayjs'
const router = useRouter()
// 数据状态
const loading = ref(false)
const orders = ref<Order[]>([])
// 筛选条件
const filters = reactive({
status: '',
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
// 订单统计
const orderStats = ref([
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
{ key: 'PENDING', label: '待付款', count: 0, icon: 'Clock', color: 'text-orange-500' },
{ key: 'PAID', label: '待发货', count: 0, icon: 'Box', color: 'text-blue-500' },
{ key: 'SHIPPED', label: '待收货', count: 0, icon: 'Van', color: 'text-purple-500' },
{ key: 'COMPLETED', label: '已完成', count: 0, icon: 'CircleCheck', color: 'text-green-500' },
{ key: 'CANCELLED', label: '已取消', count: 0, icon: 'CircleClose', color: 'text-gray-400' },
])
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 获取状态类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'PENDING': 'warning',
'PAID': 'primary',
'SHIPPED': 'primary',
'COMPLETED': 'success',
'CANCELLED': 'info',
'REFUNDED': 'danger'
}
return map[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'PENDING': '待付款',
'PAID': '待发货',
'SHIPPED': '待收货',
'COMPLETED': '已完成',
'CANCELLED': '已取消',
'REFUNDED': '已退款'
}
return map[status] || status
}
// 加载订单列表
const loadOrders = async () => {
loading.value = true
try {
const res = await orderApi.getList({
...filters,
page: pagination.page - 1,
size: pagination.size
})
if (res.success) {
orders.value = res.data.content
pagination.total = res.data.totalElements
}
} catch (error) {
console.error('加载订单失败:', error)
} finally {
loading.value = false
}
}
// 加载订单统计
const loadStatistics = async () => {
try {
const res = await orderApi.getStatistics()
if (res.success) {
const stats = res.data
orderStats.value[0].count = stats.total
orderStats.value[1].count = stats.pending
orderStats.value[2].count = stats.paid
orderStats.value[3].count = stats.shipped
orderStats.value[4].count = stats.completed
orderStats.value[5].count = stats.cancelled
}
} catch (error) {
console.error('加载统计失败:', error)
}
}
// 状态筛选
const handleStatusFilter = (status: string) => {
filters.status = status
pagination.page = 1
loadOrders()
}
// 查看详情
const handleViewDetail = (orderId: number) => {
router.push(`/order/${orderId}`)
}
// 付款
const handlePay = (order: Order) => {
ElMessageBox.confirm(
`订单金额:¥${order.totalAmount},确认付款?`,
'付款确认',
{
confirmButtonText: '确认付款',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await orderApi.pay(order.id, 'ONLINE')
ElMessage.success('付款成功')
loadOrders()
loadStatistics()
} catch (error) {
console.error('付款失败:', error)
}
})
}
// 取消订单
const handleCancel = async (order: Order) => {
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.cancel(order.id)
ElMessage.success('订单已取消')
loadOrders()
loadStatistics()
} catch (error) {
console.error('取消订单失败:', error)
}
}
// 确认收货
const handleConfirm = async (order: Order) => {
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.confirm(order.id)
ElMessage.success('已确认收货')
loadOrders()
loadStatistics()
} catch (error) {
console.error('确认收货失败:', error)
}
}
// 评价
const handleReview = (order: Order) => {
ElMessage.info('评价功能开发中...')
}
// 再次购买
const handleRebuy = (order: Order) => {
// 将商品重新加入购物车
ElMessage.success('商品已加入购物车')
router.push('/cart')
}
// 删除订单
const handleDelete = async (order: Order) => {
await ElMessageBox.confirm('确定要删除该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.delete(order.id)
ElMessage.success('订单已删除')
loadOrders()
loadStatistics()
} catch (error) {
console.error('删除订单失败:', error)
}
}
onMounted(() => {
loadOrders()
loadStatistics()
})
</script>
<style scoped lang="scss">
.orders-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="product-detail-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 :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
<el-breadcrumb-item>{{ product?.name || '商品详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<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="!product" class="text-center py-12">
<el-empty description="商品不存在" />
<el-button type="primary" @click="router.push('/products')">
返回商品列表
</el-button>
</div>
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
<!-- 左侧商品图片 -->
<div>
<div class="relative">
<img
:src="currentImage || '/default-product.png'"
:alt="product.name"
class="w-full rounded-lg"
@error="handleImageError"
>
<!-- 商品状态 -->
<div v-if="product.stock === 0" class="absolute top-4 right-4">
<el-tag type="info" size="large">已售罄</el-tag>
</div>
</div>
<!-- 图片列表 -->
<div v-if="product.images && product.images.length > 1" class="mt-4 flex gap-2 overflow-x-auto">
<img
v-for="(img, index) in product.images"
:key="index"
:src="img"
:alt="`${product.name}-${index}`"
class="w-20 h-20 object-cover rounded cursor-pointer border-2"
:class="currentImage === img ? 'border-primary-500' : 'border-transparent'"
@click="currentImage = img"
@error="handleImageError"
>
</div>
</div>
<!-- 右侧商品信息 -->
<div>
<h1 class="text-3xl font-bold mb-4">{{ product.name }}</h1>
<!-- 商品描述 -->
<p class="text-gray-600 mb-6">{{ product.description || '暂无描述' }}</p>
<!-- 价格 -->
<div class="bg-gray-50 rounded-lg p-6 mb-6">
<div class="flex items-end mb-4">
<span class="text-sm text-gray-500 mr-2">价格</span>
<span class="text-4xl font-bold text-red-500">¥{{ product.price }}</span>
</div>
<!-- 销售信息 -->
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-500">销量</span>
<p class="font-semibold">{{ product.sales || 0 }}</p>
</div>
<div>
<span class="text-gray-500">库存</span>
<p class="font-semibold">{{ product.stock }}</p>
</div>
<div>
<span class="text-gray-500">浏览</span>
<p class="font-semibold">{{ product.views || 0 }}</p>
</div>
</div>
</div>
<!-- 购买数量 -->
<div class="mb-6">
<label class="block text-sm text-gray-600 mb-2">购买数量</label>
<el-input-number
v-model="quantity"
:min="1"
:max="product.stock"
:disabled="product.stock === 0"
/>
<span class="ml-3 text-sm text-gray-500">
库存 {{ product.stock }}
</span>
</div>
<!-- 操作按钮 -->
<div class="flex gap-4 mb-6">
<el-button
type="primary"
size="large"
:disabled="product.stock === 0"
@click="handleAddToCart"
>
<el-icon class="mr-2"><ShoppingCart /></el-icon>
加入购物车
</el-button>
<el-button
type="danger"
size="large"
:disabled="product.stock === 0"
@click="handleBuyNow"
>
立即购买
</el-button>
<el-button size="large" @click="handleFavorite">
<el-icon class="mr-1">
<StarFilled v-if="isFavorited" class="text-yellow-500" />
<Star v-else />
</el-icon>
{{ isFavorited ? '已收藏' : '收藏' }}
</el-button>
</div>
<!-- 商品信息 -->
<div class="border-t pt-6">
<h3 class="font-semibold mb-4">商品信息</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">商品编号</dt>
<dd class="font-medium">{{ product.id }}</dd>
</div>
<div>
<dt class="text-gray-500">商品分类</dt>
<dd class="font-medium">{{ product.category }}</dd>
</div>
<div>
<dt class="text-gray-500">上架时间</dt>
<dd class="font-medium">{{ formatTime(product.createdAt) }}</dd>
</div>
<div>
<dt class="text-gray-500">更新时间</dt>
<dd class="font-medium">{{ formatTime(product.updatedAt) }}</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- 商品详情 -->
<div class="border-t">
<el-tabs v-model="activeTab" class="px-8">
<el-tab-pane label="商品详情" name="detail">
<div class="py-6">
<div class="prose max-w-none" v-html="product.description || '暂无详细描述'"></div>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="py-6">
<p class="text-gray-500">暂无规格参数</p>
</div>
</el-tab-pane>
<el-tab-pane label="售后保障" name="service">
<div class="py-6">
<ul class="space-y-2 text-gray-600">
<li> 7天无理由退换</li>
<li> 正品保证</li>
<li> 极速发货</li>
<li> 售后无忧</li>
</ul>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { productApi } from '@/api/modules/product'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import type { Product } from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
const loading = ref(false)
const product = ref<Product | null>(null)
const currentImage = ref('')
const quantity = ref(1)
const activeTab = ref('detail')
const isFavorited = ref(false)
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 加载商品详情
const loadProductDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await productApi.getDetail(id)
if (res.success) {
product.value = res.data
currentImage.value = res.data.imageUrl || res.data.images?.[0] || ''
}
} catch (error) {
console.error('加载商品详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
// 加入购物车
const handleAddToCart = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
if (!product.value) return
const success = await cartStore.addToCart(product.value.id, quantity.value)
if (success) {
quantity.value = 1
}
}
// 立即购买
const handleBuyNow = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
if (!product.value) return
// 先加入购物车
const success = await cartStore.addToCart(product.value.id, quantity.value)
if (success) {
// 跳转到购物车
router.push('/cart')
}
}
// 收藏/取消收藏
const handleFavorite = () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
isFavorited.value = !isFavorited.value
ElMessage.success(isFavorited.value ? '已收藏' : '已取消收藏')
}
onMounted(() => {
loadProductDetail()
})
</script>
<style scoped lang="scss">
.product-detail-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.prose {
max-width: none;
:deep(img) {
max-width: 100%;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div class="products-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<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>
商品列表
</h1>
<p class="text-gray-600">精选好物品质保证</p>
</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"
placeholder="选择分类"
clearable
style="width: 150px"
@change="loadProducts"
>
<el-option
v-for="cat in categories"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
<!-- 价格区间 -->
<div class="flex items-center gap-2">
<el-input-number
v-model="filters.minPrice"
:min="0"
placeholder="最低价"
style="width: 120px"
@change="loadProducts"
/>
<span>-</span>
<el-input-number
v-model="filters.maxPrice"
:min="0"
placeholder="最高价"
style="width: 120px"
@change="loadProducts"
/>
</div>
<!-- 排序 -->
<el-radio-group v-model="filters.sort" @change="loadProducts">
<el-radio-button label="default">默认</el-radio-button>
<el-radio-button label="price-asc">价格升序</el-radio-button>
<el-radio-button label="price-desc">价格降序</el-radio-button>
<el-radio-button label="sales">销量</el-radio-button>
</el-radio-group>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
placeholder="搜索商品"
style="width: 200px"
clearable
@keyup.enter="loadProducts"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadProducts">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<!-- 商品列表 -->
<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="products.length === 0" class="text-center py-12">
<el-empty description="暂无商品" />
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="item in products"
:key="item.id"
:data="item"
@add-to-cart="handleAddToCart"
/>
</div>
<!-- 分页 -->
<div 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="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadProducts"
@current-change="loadProducts"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import ProductCard from '@/components/business/ProductCard.vue'
import { productApi } from '@/api/modules/product'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import type { Product } from '@/types/api'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const products = ref<Product[]>([])
const categories = ref<string[]>([])
// 筛选条件
const filters = reactive({
keyword: '',
category: '',
minPrice: undefined as number | undefined,
maxPrice: undefined as number | undefined,
sort: 'default'
})
// 分页
const pagination = reactive({
page: 1,
size: 12,
total: 0
})
// 加载商品列表
const loadProducts = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page - 1,
size: pagination.size
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.category) params.category = filters.category
if (filters.minPrice !== undefined) params.minPrice = filters.minPrice
if (filters.maxPrice !== undefined) params.maxPrice = filters.maxPrice
// 处理排序
if (filters.sort === 'price-asc') {
params.sort = 'price'
params.order = 'asc'
} else if (filters.sort === 'price-desc') {
params.sort = 'price'
params.order = 'desc'
} else if (filters.sort === 'sales') {
params.sort = 'sales'
params.order = 'desc'
}
const res = await productApi.getList(params)
if (res.success) {
products.value = res.data.content
pagination.total = res.data.totalElements
}
} catch (error) {
console.error('加载商品列表失败:', error)
} finally {
loading.value = false
}
}
// 加载分类
const loadCategories = async () => {
try {
const res = await productApi.getCategories()
if (res.success) {
categories.value = res.data
}
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 添加到购物车
const handleAddToCart = async (productId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
await cartStore.addToCart(productId)
}
onMounted(() => {
// 从路由参数获取搜索关键词
if (route.query.keyword) {
filters.keyword = route.query.keyword as string
}
loadCategories()
loadProducts()
})
</script>
<style scoped lang="scss">
.products-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,165 @@
<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">
<!-- Logo -->
<div class="text-center mb-8">
<el-icon :size="48" class="text-red-500 mb-4">
<Lightning />
</el-icon>
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
<p class="text-gray-600 mt-2">登录到秒杀系统</p>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
size="large"
placeholder="请输入用户名"
prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
size="large"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<div class="flex justify-between items-center w-full">
<el-checkbox v-model="loginForm.rememberMe">记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleLogin"
>
</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">
立即注册
</router-link>
</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>普通用户: user / 123456</p>
<p>管理员: admin / 123456</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import type { LoginParams } from '@/types/api'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const loginForm = reactive<LoginParams>({
username: '',
password: '',
rememberMe: false
})
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
]
}
// 登录
const handleLogin = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const success = await userStore.login(loginForm)
if (success) {
// 登录成功router跳转已在store中处理
}
} finally {
loading.value = false
}
}
})
}
// 快速登录
const quickLogin = (type: 'user' | 'admin') => {
if (type === 'user') {
loginForm.username = 'user'
loginForm.password = '123456'
} else {
loginForm.username = 'admin'
loginForm.password = '123456'
}
loginForm.rememberMe = true
handleLogin()
}
</script>
<style scoped lang="scss">
.login-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="profile-page">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- 左侧菜单 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 用户头像 -->
<div class="text-center mb-6">
<el-avatar :size="80" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
</el-avatar>
<h3 class="mt-3 font-semibold">{{ userStore.username }}</h3>
<p class="text-sm text-gray-500">{{ userStore.user?.email }}</p>
</div>
<!-- 菜单列表 -->
<el-menu
:default-active="activeMenu"
@select="handleMenuSelect"
>
<el-menu-item index="info">
<el-icon><User /></el-icon>
<span>基本信息</span>
</el-menu-item>
<el-menu-item index="security">
<el-icon><Lock /></el-icon>
<span>账号安全</span>
</el-menu-item>
<el-menu-item index="address">
<el-icon><Location /></el-icon>
<span>收货地址</span>
</el-menu-item>
<el-menu-item index="orders">
<el-icon><List /></el-icon>
<span>我的订单</span>
</el-menu-item>
</el-menu>
</div>
</div>
<!-- 右侧内容 -->
<div class="lg:col-span-3">
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 基本信息 -->
<div v-if="activeMenu === 'info'">
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
<el-form
ref="infoFormRef"
:model="infoForm"
:rules="infoRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="infoForm.username" disabled />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="infoForm.email" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="infoForm.phone" />
</el-form-item>
<el-form-item label="头像">
<div class="flex items-center gap-4">
<el-avatar :size="60" :src="infoForm.avatar">
{{ infoForm.username[0] }}
</el-avatar>
<el-button>更换头像</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveInfo">
保存修改
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 账号安全 -->
<div v-else-if="activeMenu === 'security'">
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChangePassword">
修改密码
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 收货地址 -->
<div v-else-if="activeMenu === 'address'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">收货地址</h2>
<el-button type="primary" @click="handleAddAddress">
<el-icon class="mr-1"><Plus /></el-icon>
添加地址
</el-button>
</div>
<div class="space-y-4">
<div
v-for="addr in addresses"
:key="addr.id"
class="border rounded-lg p-4"
>
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="font-semibold">{{ addr.name }}</span>
<span class="text-gray-500">{{ addr.phone }}</span>
<el-tag v-if="addr.isDefault" type="primary" size="small">
默认
</el-tag>
</div>
<p class="text-gray-600">
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}
</p>
</div>
<div class="space-x-2">
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
</div>
</div>
</div>
<el-empty v-if="addresses.length === 0" description="暂无收货地址" />
</div>
</div>
<!-- 我的订单 -->
<div v-else-if="activeMenu === 'orders'">
<h2 class="text-xl font-semibold mb-6">我的订单</h2>
<p class="text-gray-500">请访问 <router-link to="/orders" class="text-primary-500">订单页面</router-link> 查看详细订单信息</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { userApi } from '@/api/modules/user'
const router = useRouter()
const userStore = useUserStore()
const activeMenu = ref('info')
const infoFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
// 基本信息表单
const infoForm = reactive({
username: userStore.user?.username || '',
email: userStore.user?.email || '',
phone: userStore.user?.phone || '',
avatar: userStore.user?.avatar || ''
})
const infoRules: FormRules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
// 修改密码表单
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const validatePassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== passwordForm.newPassword) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const passwordRules: FormRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePassword, trigger: 'blur' }
]
}
// 收货地址
const addresses = ref<any[]>([])
// 菜单选择
const handleMenuSelect = (index: string) => {
activeMenu.value = index
if (index === 'orders') {
router.push('/orders')
}
}
// 保存基本信息
const handleSaveInfo = async () => {
if (!infoFormRef.value) return
await infoFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await userApi.updateInfo(infoForm)
if (res.success) {
userStore.updateUserInfo(infoForm)
ElMessage.success('保存成功')
}
} catch (error) {
console.error('保存失败:', error)
}
}
})
}
// 修改密码
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await userApi.changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword
})
if (res.success) {
ElMessage.success('密码修改成功,请重新登录')
userStore.logout()
}
} catch (error) {
console.error('修改密码失败:', error)
}
}
})
}
// 添加地址
const handleAddAddress = () => {
ElMessage.info('功能开发中...')
}
onMounted(() => {
// 加载用户信息
userStore.getUserInfo()
})
</script>
<style scoped lang="scss">
.profile-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,197 @@
<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">
<!-- Logo -->
<div class="text-center mb-8">
<el-icon :size="48" class="text-red-500 mb-4">
<Lightning />
</el-icon>
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
<p class="text-gray-600 mt-2">加入秒杀系统</p>
</div>
<!-- 注册表单 -->
<el-form
ref="formRef"
:model="registerForm"
:rules="rules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
size="large"
placeholder="请输入用户名"
prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
size="large"
placeholder="请输入邮箱"
prefix-icon="Message"
clearable
/>
</el-form-item>
<el-form-item prop="phone">
<el-input
v-model="registerForm.phone"
size="large"
placeholder="请输入手机号(选填)"
prefix-icon="Phone"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
size="large"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
size="large"
placeholder="请确认密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item prop="agreement">
<el-checkbox v-model="registerForm.agreement">
我已阅读并同意
<el-link type="primary" :underline="false">用户协议</el-link>
<el-link type="primary" :underline="false">隐私政策</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleRegister"
>
</el-button>
</el-form-item>
<div class="text-center">
<span class="text-gray-600">已有账号</span>
<router-link to="/login" class="text-primary-500 hover:underline">
立即登录
</router-link>
</div>
</el-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const registerForm = reactive({
username: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
agreement: false
})
const validatePassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const validateAgreement = (rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请同意用户协议'))
} else {
callback()
}
}
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePassword, trigger: 'blur' }
],
agreement: [
{ required: true, validator: validateAgreement, trigger: 'change' }
]
}
// 注册
const handleRegister = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const { confirmPassword, agreement, ...params } = registerForm
const success = await userStore.register(params)
if (success) {
// 注册成功跳转到登录页已在store中处理
}
} finally {
loading.value = false
}
}
})
}
</script>
<style scoped lang="scss">
.register-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>

View File

@@ -0,0 +1,44 @@
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
export function setupGuards(router: Router) {
// 路由前置守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
document.title = `${to.meta.title || '秒杀系统'} - 高并发电商抢购平台`
// 需要登录的页面
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
ElMessage.warning('请先登录')
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// 需要管理员权限的页面
if (to.meta.requiresAdmin && userStore.user?.role !== 'ADMIN') {
ElMessage.error('无权访问')
next('/')
return
}
// 已登录用户访问登录/注册页面
if ((to.path === '/login' || to.path === '/register') && userStore.isLoggedIn) {
next('/')
return
}
next()
})
// 路由后置守卫
router.afterEach((to, from) => {
// 页面切换后滚动到顶部
window.scrollTo(0, 0)
})
}

View File

@@ -0,0 +1,139 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { setupGuards } from './guards'
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{
path: '',
name: 'Home',
component: () => import('@/pages/home/index.vue'),
meta: { title: '首页' }
},
{
path: 'flashsale',
name: 'FlashSale',
component: () => import('@/pages/flashsale/index.vue'),
meta: { title: '秒杀活动' }
},
{
path: 'flashsale/:id',
name: 'FlashSaleDetail',
component: () => import('@/pages/flashsale/detail.vue'),
meta: { title: '秒杀详情' }
},
{
path: 'products',
name: 'Products',
component: () => import('@/pages/product/index.vue'),
meta: { title: '商品列表' }
},
{
path: 'product/:id',
name: 'ProductDetail',
component: () => import('@/pages/product/detail.vue'),
meta: { title: '商品详情' }
},
{
path: 'cart',
name: 'Cart',
component: () => import('@/pages/cart/index.vue'),
meta: { title: '购物车', requiresAuth: true }
},
{
path: 'orders',
name: 'Orders',
component: () => import('@/pages/order/index.vue'),
meta: { title: '我的订单', requiresAuth: true }
},
{
path: 'order/:id',
name: 'OrderDetail',
component: () => import('@/pages/order/detail.vue'),
meta: { title: '订单详情', requiresAuth: true }
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/pages/user/profile.vue'),
meta: { title: '个人中心', requiresAuth: true }
}
]
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/user/login.vue'),
meta: { title: '登录' }
},
{
path: '/register',
name: 'Register',
component: () => import('@/pages/user/register.vue'),
meta: { title: '注册' }
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/pages/admin/dashboard.vue'),
meta: { title: '管理后台' }
},
{
path: 'products',
name: 'AdminProducts',
component: () => import('@/pages/admin/products.vue'),
meta: { title: '商品管理' }
},
{
path: 'flashsales',
name: 'AdminFlashSales',
component: () => import('@/pages/admin/flashsales.vue'),
meta: { title: '秒杀管理' }
},
{
path: 'orders',
name: 'AdminOrders',
component: () => import('@/pages/admin/orders.vue'),
meta: { title: '订单管理' }
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/pages/admin/users.vue'),
meta: { title: '用户管理' }
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/pages/error/404.vue'),
meta: { title: '页面未找到' }
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 设置路由守卫
setupGuards(router)
export default router

View File

@@ -0,0 +1,167 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem } from '@/types/api'
import { cartApi } from '@/api/modules/cart'
import { ElMessage } from 'element-plus'
export const useCartStore = defineStore('cart', () => {
// 状态
const items = ref<CartItem[]>([])
const loading = ref(false)
// 计算属性
const itemCount = computed(() => items.value.length)
const totalQuantity = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const selectedItems = computed(() =>
items.value.filter(item => item.selected)
)
const selectedCount = computed(() => selectedItems.value.length)
const totalPrice = computed(() =>
selectedItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const isAllSelected = computed(() =>
items.value.length > 0 && items.value.every(item => item.selected)
)
// 获取购物车数据
const fetchCart = async () => {
loading.value = true
try {
const res = await cartApi.getCart()
if (res.success) {
items.value = res.data || []
}
} catch (error) {
console.error('获取购物车失败:', error)
} finally {
loading.value = false
}
}
// 添加到购物车
const addToCart = async (productId: number, quantity = 1) => {
try {
const res = await cartApi.addToCart({ productId, quantity })
if (res.success) {
ElMessage.success('已添加到购物车')
await fetchCart()
return true
}
return false
} catch (error) {
console.error('添加到购物车失败:', error)
return false
}
}
// 更新数量
const updateQuantity = async (itemId: string, quantity: number) => {
if (quantity < 1) return
try {
const res = await cartApi.updateQuantity(itemId, quantity)
if (res.success) {
const item = items.value.find(i => i.id === itemId)
if (item) {
item.quantity = quantity
}
}
} catch (error) {
console.error('更新数量失败:', error)
}
}
// 删除商品
const removeItem = async (itemId: string) => {
try {
const res = await cartApi.removeItem(itemId)
if (res.success) {
items.value = items.value.filter(i => i.id !== itemId)
ElMessage.success('已删除')
}
} catch (error) {
console.error('删除失败:', error)
}
}
// 批量删除
const removeSelected = async () => {
const ids = selectedItems.value.map(item => item.id)
if (ids.length === 0) return
try {
const res = await cartApi.batchRemove(ids)
if (res.success) {
items.value = items.value.filter(i => !ids.includes(i.id))
ElMessage.success('已批量删除')
}
} catch (error) {
console.error('批量删除失败:', error)
}
}
// 清空购物车
const clearCart = async () => {
try {
const res = await cartApi.clearCart()
if (res.success) {
items.value = []
ElMessage.success('购物车已清空')
}
} catch (error) {
console.error('清空购物车失败:', error)
}
}
// 切换选中状态
const toggleSelect = (itemId: string) => {
const item = items.value.find(i => i.id === itemId)
if (item) {
item.selected = !item.selected
}
}
// 全选/取消全选
const toggleSelectAll = () => {
const allSelected = isAllSelected.value
items.value.forEach(item => {
item.selected = !allSelected
})
}
// 获取购物车数量(仅数量)
const getCartCount = async () => {
try {
const res = await cartApi.getCount()
if (res.success) {
return res.data.count || 0
}
return 0
} catch (error) {
console.error('获取购物车数量失败:', error)
return 0
}
}
return {
items,
loading,
itemCount,
totalQuantity,
selectedItems,
selectedCount,
totalPrice,
isAllSelected,
fetchCart,
addToCart,
updateQuantity,
removeItem,
removeSelected,
clearCart,
toggleSelect,
toggleSelectAll,
getCartCount,
}
})

View File

@@ -0,0 +1,128 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, LoginParams, RegisterParams } from '@/types/api'
import { userApi } from '@/api/modules/user'
import { ElMessage } from 'element-plus'
import router from '@/router'
export const useUserStore = defineStore('user', () => {
// 状态
const user = ref<User | null>(null)
const token = ref<string>('')
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'ADMIN')
const username = computed(() => user.value?.username || '')
// 从localStorage恢复登录状态
const initializeAuth = () => {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken && savedUser) {
token.value = savedToken
user.value = JSON.parse(savedUser)
}
}
// 登录
const login = async (params: LoginParams) => {
try {
const res = await userApi.login(params)
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))
ElMessage.success('登录成功')
// 跳转到之前的页面或首页
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/')
return true
}
return false
} catch (error) {
console.error('登录失败:', error)
return false
}
}
// 注册
const register = async (params: RegisterParams) => {
try {
const res = await userApi.register(params)
if (res.success) {
ElMessage.success('注册成功,请登录')
router.push('/login')
return true
}
return false
} catch (error) {
console.error('注册失败:', error)
return false
}
}
// 退出登录
const logout = () => {
user.value = null
token.value = ''
// 清除localStorage
localStorage.removeItem('token')
localStorage.removeItem('user')
ElMessage.success('已退出登录')
router.push('/login')
}
// 获取用户信息
const getUserInfo = async () => {
if (!token.value) return
try {
const res = await userApi.getInfo()
if (res.success) {
user.value = res.data
localStorage.setItem('user', JSON.stringify(user.value))
}
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
// 更新用户信息
const updateUserInfo = (info: Partial<User>) => {
if (user.value) {
user.value = { ...user.value, ...info }
localStorage.setItem('user', JSON.stringify(user.value))
}
}
// 初始化
initializeAuth()
return {
user,
token,
isLoggedIn,
isAdmin,
username,
login,
register,
logout,
getUserInfo,
updateUserInfo,
}
})

View File

@@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
// 自定义变量
:root {
--primary-color: #ef4444;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--info-color: #3b82f6;
}
// 全局样式重置
* {
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;
}
// 滚动条样式
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
// 动画类
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-2px);
}
20%, 40%, 60%, 80% {
transform: translateX(2px);
}
}
.shake {
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;
}
.card-shadow {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
}
// Element Plus 样式覆盖
.el-button--danger {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.el-message-box {
border-radius: 8px;
}
.el-notification {
border-radius: 8px;
}

158
flash-sale-frontend/src/types/api.d.ts vendored Normal file
View File

@@ -0,0 +1,158 @@
// API响应基础类型
export interface ApiResponse<T = any> {
code: number
success: boolean
message: string
data: T
timestamp?: number
}
// 分页参数
export interface PageParams {
page: number
size: number
sort?: string
order?: 'asc' | 'desc'
}
// 分页响应
export interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
first: boolean
last: boolean
}
// 用户类型
export interface User {
id: number
username: string
email: string
phone?: string
avatar?: string
role: 'USER' | 'ADMIN'
status: 'ACTIVE' | 'INACTIVE' | 'BANNED'
createdAt: string
updatedAt: string
}
// 登录参数
export interface LoginParams {
username: string
password: string
rememberMe?: boolean
}
// 注册参数
export interface RegisterParams {
username: string
password: string
email: string
phone?: string
}
// 商品类型
export interface Product {
id: number
name: string
description: string
price: number
stock: number
imageUrl: string
images?: string[]
category: string
status: 'ON_SALE' | 'OFF_SALE' | 'SOLD_OUT'
sales: number
views: number
createdAt: string
updatedAt: string
}
// 秒杀活动类型
export interface FlashSale {
id: number
productId: number
productName: string
productImageUrl: string
originalPrice: number
flashPrice: number
flashStock: number
remainingStock: number
startTime: string
endTime: string
status: 'UPCOMING' | 'ACTIVE' | 'ENDED'
limitPerUser: number
description?: string
createdAt: string
updatedAt: string
}
// 购物车项
export interface CartItem {
id: string
productId: number
productName: string
productImage: string
price: number
quantity: number
stock: number
selected: boolean
createdAt: string
}
// 订单类型
export interface Order {
id: number
orderNo: string
userId: number
username: string
totalAmount: number
paymentAmount: number
paymentMethod?: string
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDED'
items: OrderItem[]
address?: OrderAddress
remark?: string
createdAt: string
updatedAt: string
paidAt?: string
shippedAt?: string
completedAt?: string
}
// 订单项
export interface OrderItem {
id: number
productId: number
productName: string
productImage: string
price: number
quantity: number
subtotal: number
}
// 订单地址
export interface OrderAddress {
name: string
phone: string
province: string
city: string
district: string
address: string
zipCode?: string
}
// 统计数据
export interface Statistics {
totalUsers: number
totalProducts: number
totalOrders: number
totalSales: number
todayOrders: number
todaySales: number
activeFlashSales: number
onlineUsers: number
}

View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
},
animation: {
'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "element-plus/global", "node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia'],
'element': ['element-plus', '@element-plus/icons-vue'],
'utils': ['axios', 'dayjs', '@vueuse/core'],
'charts': ['echarts', 'vue-echarts'],
},
},
},
},
})

View File

@@ -0,0 +1,414 @@
package com.org.flashsalesystem.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeoutException;
/**
* 全局异常处理器
* 统一处理应用中的各种异常,提供一致的错误响应格式
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 业务异常处理
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.warn("业务异常: {} - {}", e.getErrorCode(), e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Business Error")
.message(e.getMessage())
.errorCode(e.getErrorCode())
.path(request.getRequestURI())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 秒杀相关异常处理
*/
@ExceptionHandler(FlashSaleException.class)
public ResponseEntity<ErrorResponse> handleFlashSaleException(FlashSaleException e, HttpServletRequest request) {
log.warn("秒杀异常: {} - {}", e.getErrorCode(), e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Flash Sale Error")
.message(e.getMessage())
.errorCode(e.getErrorCode())
.path(request.getRequestURI())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 限流异常处理
*/
@ExceptionHandler(RateLimitException.class)
public ResponseEntity<ErrorResponse> handleRateLimitException(RateLimitException e, HttpServletRequest request) {
log.warn("限流异常: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.TOO_MANY_REQUESTS.value())
.error("Rate Limit Exceeded")
.message(e.getMessage())
.errorCode("RATE_LIMIT_EXCEEDED")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(errorResponse);
}
/**
* 数据验证异常处理
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
log.warn("数据验证异常: {}", e.getMessage());
StringBuilder errorMessages = new StringBuilder();
for (ObjectError error : e.getBindingResult().getAllErrors()) {
if (errorMessages.length() > 0) {
errorMessages.append("; ");
}
errorMessages.append(error.getDefaultMessage());
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Error")
.message(errorMessages.toString())
.errorCode("VALIDATION_ERROR")
.path(request.getRequestURI())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 参数绑定异常处理
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ErrorResponse> handleBindException(BindException e, HttpServletRequest request) {
log.warn("参数绑定异常: {}", e.getMessage());
StringBuilder errorMessages = new StringBuilder();
for (ObjectError error : e.getBindingResult().getAllErrors()) {
if (errorMessages.length() > 0) {
errorMessages.append("; ");
}
errorMessages.append(error.getDefaultMessage());
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Parameter Binding Error")
.message(errorMessages.toString())
.errorCode("BINDING_ERROR")
.path(request.getRequestURI())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 约束违规异常处理
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException e, HttpServletRequest request) {
log.warn("约束违规异常: {}", e.getMessage());
StringBuilder errorMessages = new StringBuilder();
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
for (ConstraintViolation<?> violation : violations) {
if (errorMessages.length() > 0) {
errorMessages.append("; ");
}
errorMessages.append(violation.getMessage());
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Constraint Violation")
.message(errorMessages.toString())
.errorCode("CONSTRAINT_VIOLATION")
.path(request.getRequestURI())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 超时异常处理
*/
@ExceptionHandler(TimeoutException.class)
public ResponseEntity<ErrorResponse> handleTimeoutException(TimeoutException e, HttpServletRequest request) {
log.error("超时异常: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.REQUEST_TIMEOUT.value())
.error("Timeout Error")
.message("请求超时,请稍后重试")
.errorCode("TIMEOUT_ERROR")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body(errorResponse);
}
/**
* 非法参数异常处理
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) {
log.warn("非法参数异常: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Illegal Argument")
.message(e.getMessage())
.errorCode("ILLEGAL_ARGUMENT")
.path(request.getRequestURI())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 空指针异常处理
*/
@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ErrorResponse> handleNullPointerException(NullPointerException e, HttpServletRequest request) {
log.error("空指针异常: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Null Pointer Error")
.message("系统内部错误,请联系管理员")
.errorCode("NULL_POINTER_ERROR")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 运行时异常处理
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
log.error("运行时异常: {}", e.getMessage(), e);
// 对于已知的业务异常,使用友好的错误信息
String message = e.getMessage();
if (message == null || message.trim().isEmpty()) {
message = "系统繁忙,请稍后重试";
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Runtime Error")
.message(message)
.errorCode("RUNTIME_ERROR")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 通用异常处理
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception e, HttpServletRequest request) {
log.error("系统异常: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("System Error")
.message("系统异常,请联系管理员")
.errorCode("SYSTEM_ERROR")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 统一错误响应格式
*/
public static class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String errorCode;
private String path;
private Map<String, Object> details;
public ErrorResponse() {
this.details = new HashMap<>();
}
public static ErrorResponseBuilder builder() {
return new ErrorResponseBuilder();
}
// Getters and Setters
public LocalDateTime getTimestamp() { return timestamp; }
public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getErrorCode() { return errorCode; }
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public Map<String, Object> getDetails() { return details; }
public void setDetails(Map<String, Object> details) { this.details = details; }
public static class ErrorResponseBuilder {
private ErrorResponse errorResponse = new ErrorResponse();
public ErrorResponseBuilder timestamp(LocalDateTime timestamp) {
errorResponse.setTimestamp(timestamp);
return this;
}
public ErrorResponseBuilder status(int status) {
errorResponse.setStatus(status);
return this;
}
public ErrorResponseBuilder error(String error) {
errorResponse.setError(error);
return this;
}
public ErrorResponseBuilder message(String message) {
errorResponse.setMessage(message);
return this;
}
public ErrorResponseBuilder errorCode(String errorCode) {
errorResponse.setErrorCode(errorCode);
return this;
}
public ErrorResponseBuilder path(String path) {
errorResponse.setPath(path);
return this;
}
public ErrorResponseBuilder detail(String key, Object value) {
errorResponse.getDetails().put(key, value);
return this;
}
public ErrorResponse build() {
return errorResponse;
}
}
}
/**
* 业务异常类
*/
public static class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String message) {
super(message);
this.errorCode = "BUSINESS_ERROR";
}
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
this.errorCode = "BUSINESS_ERROR";
}
public String getErrorCode() {
return errorCode;
}
}
/**
* 秒杀异常类
*/
public static class FlashSaleException extends RuntimeException {
private final String errorCode;
public FlashSaleException(String message) {
super(message);
this.errorCode = "FLASH_SALE_ERROR";
}
public FlashSaleException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
/**
* 限流异常类
*/
public static class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
public RateLimitException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -76,4 +77,17 @@ public class WebConfig implements WebMvcConfigurer {
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
}
/**
* CORS跨域配置
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

View File

@@ -0,0 +1,230 @@
package com.org.flashsalesystem.controller;
import com.org.flashsalesystem.dto.FlashSaleDTO;
import com.org.flashsalesystem.dto.ProductDTO;
import com.org.flashsalesystem.entity.Product;
import com.org.flashsalesystem.repository.ProductRepository;
import com.org.flashsalesystem.service.FlashSaleService;
import com.org.flashsalesystem.service.ProductService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
/**
* API控制器 - 为Vue前端提供REST接口
*/
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "API接口", description = "前端API接口")
public class ApiController {
private final ProductRepository productRepository;
private final ProductService productService;
private final FlashSaleService flashSaleService;
/**
* 获取热门商品
*/
@GetMapping("/products/hot")
@Operation(summary = "获取热门商品")
public ResponseEntity<Map<String, Object>> getHotProducts(
@RequestParam(defaultValue = "8") int limit) {
Map<String, Object> response = new HashMap<>();
try {
// 获取前N个商品作为热门商品
Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "id"));
Page<Product> productPage = productRepository.findAll(pageable);
List<Map<String, Object>> products = new ArrayList<>();
for (Product product : productPage.getContent()) {
Map<String, Object> item = new HashMap<>();
item.put("id", product.getId());
item.put("name", product.getName());
item.put("description", product.getDescription());
item.put("price", product.getPrice());
item.put("stock", product.getStock());
item.put("image", product.getImageUrl());
item.put("category", "");
products.add(item);
}
response.put("success", true);
response.put("data", products);
} catch (Exception e) {
log.error("获取热门商品失败", e);
response.put("success", false);
response.put("message", "获取热门商品失败");
}
return ResponseEntity.ok(response);
}
/**
* 获取活跃的秒杀活动
*/
@GetMapping("/flashsales/active")
@Operation(summary = "获取活跃的秒杀活动")
public ResponseEntity<Map<String, Object>> getActiveFlashSales() {
Map<String, Object> response = new HashMap<>();
try {
List<FlashSaleDTO> flashSales = flashSaleService.getActiveFlashSales();
// 转换数据格式
List<Map<String, Object>> result = new ArrayList<>();
for (FlashSaleDTO flashSale : flashSales) {
Map<String, Object> item = new HashMap<>();
item.put("id", flashSale.getId());
item.put("productId", flashSale.getProductId());
item.put("productName", flashSale.getProductName());
item.put("productImage", "");
item.put("originalPrice", flashSale.getOriginalPrice());
item.put("flashPrice", flashSale.getFlashPrice());
item.put("flashStock", flashSale.getFlashStock());
item.put("startTime", flashSale.getStartTime());
item.put("endTime", flashSale.getEndTime());
item.put("status", flashSale.getStatus());
result.add(item);
}
response.put("success", true);
response.put("data", result);
} catch (Exception e) {
log.error("获取活跃秒杀活动失败", e);
response.put("success", false);
response.put("message", "获取活跃秒杀活动失败");
}
return ResponseEntity.ok(response);
}
/**
* 参与秒杀
*/
@PostMapping("/flashsales/participate")
@Operation(summary = "参与秒杀")
public ResponseEntity<Map<String, Object>> participate(
@RequestBody Map<String, Object> request,
HttpServletRequest httpRequest) {
Map<String, Object> response = new HashMap<>();
try {
// 从session获取用户ID
Long userId = (Long) httpRequest.getSession().getAttribute("userId");
if (userId == null) {
response.put("success", false);
response.put("message", "请先登录");
return ResponseEntity.ok(response);
}
Long flashSaleId = Long.valueOf(request.get("flashSaleId").toString());
Integer quantity = request.containsKey("quantity") ?
Integer.valueOf(request.get("quantity").toString()) : 1;
// 创建参与DTO
FlashSaleDTO.ParticipateDTO participateDTO = new FlashSaleDTO.ParticipateDTO();
participateDTO.setFlashSaleId(flashSaleId);
participateDTO.setQuantity(quantity);
// 调用秒杀服务
FlashSaleDTO.ResultDTO result = flashSaleService.participateFlashSale(userId, participateDTO);
response.put("success", result.getSuccess());
response.put("message", result.getMessage());
if (result.getOrderId() != null) {
response.put("orderId", result.getOrderId());
}
} catch (Exception e) {
log.error("参与秒杀失败", e);
response.put("success", false);
response.put("message", e.getMessage());
}
return ResponseEntity.ok(response);
}
/**
* 获取商品列表
*/
@GetMapping("/products")
@Operation(summary = "获取商品列表")
public ResponseEntity<Map<String, Object>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size) {
Map<String, Object> response = new HashMap<>();
try {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
Page<Product> productPage = productRepository.findAll(pageable);
List<Map<String, Object>> products = new ArrayList<>();
for (Product product : productPage.getContent()) {
Map<String, Object> item = new HashMap<>();
item.put("id", product.getId());
item.put("name", product.getName());
item.put("description", product.getDescription());
item.put("price", product.getPrice());
item.put("stock", product.getStock());
item.put("image", product.getImageUrl());
item.put("category", "");
products.add(item);
}
response.put("success", true);
response.put("list", products);
response.put("total", productPage.getTotalElements());
} catch (Exception e) {
log.error("获取商品列表失败", e);
response.put("success", false);
response.put("message", "获取商品列表失败");
}
return ResponseEntity.ok(response);
}
/**
* 获取秒杀活动列表
*/
@GetMapping("/flashsales")
@Operation(summary = "获取秒杀活动列表")
public ResponseEntity<Map<String, Object>> getFlashSales() {
Map<String, Object> response = new HashMap<>();
try {
List<FlashSaleDTO> flashSales = flashSaleService.getActiveFlashSales();
response.put("success", true);
response.put("list", flashSales);
response.put("total", flashSales.size());
} catch (Exception e) {
log.error("获取秒杀活动列表失败", e);
response.put("success", false);
response.put("message", "获取秒杀活动列表失败");
}
return ResponseEntity.ok(response);
}
}

View File

@@ -81,4 +81,9 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
* 统计库存小于指定数量的商品数量
*/
long countByStockLessThan(Integer stock);
/**
* 根据名称模糊查询(忽略大小写)
*/
Page<Product> findByNameContainingIgnoreCase(String name, Pageable pageable);
}

View File

@@ -45,7 +45,7 @@ spring:
# nodes: 42.192.62.91:7000,42.192.62.91:7001,42.192.62.91:7002,42.192.62.91:7003,42.192.62.91:7004,42.192.62.91:7005
# 通用配置
password:
# password:
timeout: 5000
jedis:
pool:

View File

@@ -0,0 +1,515 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统监控 - 管理后台</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.monitor-card {
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.metric-item {
text-align: center;
padding: 20px;
}
.metric-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 5px;
}
.metric-label {
color: #6c757d;
font-size: 0.9rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-online { background-color: #28a745; }
.status-warning { background-color: #ffc107; }
.status-offline { background-color: #dc3545; }
.chart-container {
position: relative;
height: 300px;
}
.log-container {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
}
.log-line {
margin-bottom: 5px;
padding: 2px 0;
}
.log-error { color: #dc3545; }
.log-warn { color: #ffc107; }
.log-info { color: #17a2b8; }
.log-debug { color: #6c757d; }
.refresh-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/admin">
<i class="fas fa-tachometer-alt me-2"></i>管理后台
</a>
<div class="navbar-nav ms-auto">
<a class="nav-link" href="/admin">
<i class="fas fa-arrow-left me-1"></i>返回首页
</a>
</div>
</div>
</nav>
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col">
<h2>
<i class="fas fa-chart-line me-2"></i>系统监控
<button class="btn btn-outline-primary btn-sm ms-3 refresh-btn" onclick="refreshAll()">
<i class="fas fa-sync-alt"></i> 刷新
</button>
</h2>
</div>
</div>
<!-- 系统状态卡片 -->
<div class="row mb-4">
<div class="col-lg-3 col-md-6">
<div class="card monitor-card bg-primary text-white">
<div class="card-body metric-item">
<div class="metric-value" id="cpu-usage">0%</div>
<div class="metric-label">CPU 使用率</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card monitor-card bg-info text-white">
<div class="card-body metric-item">
<div class="metric-value" id="memory-usage">0%</div>
<div class="metric-label">内存使用率</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card monitor-card bg-success text-white">
<div class="card-body metric-item">
<div class="metric-value" id="active-users">0</div>
<div class="metric-label">在线用户</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card monitor-card bg-warning text-white">
<div class="card-body metric-item">
<div class="metric-value" id="total-requests">0</div>
<div class="metric-label">今日请求</div>
</div>
</div>
</div>
</div>
<!-- 服务状态 -->
<div class="row mb-4">
<div class="col-lg-6">
<div class="card monitor-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-server me-2"></i>服务状态
</h5>
<button class="btn btn-outline-secondary btn-sm" onclick="checkServices()">
<i class="fas fa-check"></i> 检查
</button>
</div>
<div class="card-body">
<div class="list-group list-group-flush" id="service-status">
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<span class="status-indicator status-online"></span>
应用服务
</div>
<span class="badge bg-success rounded-pill">运行中</span>
</div>
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<span class="status-indicator" id="redis-indicator"></span>
Redis 服务
</div>
<span class="badge rounded-pill" id="redis-status">检查中...</span>
</div>
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<span class="status-indicator" id="mysql-indicator"></span>
MySQL 服务
</div>
<span class="badge rounded-pill" id="mysql-status">检查中...</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card monitor-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-area me-2"></i>系统性能趋势
</h5>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="performance-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- 业务监控 -->
<div class="row mb-4">
<div class="col-lg-8">
<div class="card monitor-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-bolt me-2"></i>秒杀活动监控
</h5>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="loadFlashSaleStats()">
<i class="fas fa-sync-alt"></i> 刷新
</button>
</div>
</div>
<div class="card-body">
<div id="flashsale-monitor">
<div class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
<p class="mt-3 text-muted">加载中...</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card monitor-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>系统告警
</h5>
</div>
<div class="card-body">
<div id="system-alerts">
<div class="alert alert-success" role="alert">
<i class="fas fa-check-circle me-2"></i>
系统运行正常
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 实时日志 -->
<div class="row mb-4">
<div class="col">
<div class="card monitor-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-file-alt me-2"></i>实时日志
</h5>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick="clearLogs()">
<i class="fas fa-trash"></i> 清空
</button>
<button class="btn btn-outline-primary" onclick="toggleAutoRefresh()">
<i class="fas fa-play" id="auto-refresh-icon"></i>
<span id="auto-refresh-text">自动刷新</span>
</button>
</div>
</div>
<div class="card-body p-0">
<div class="log-container" id="log-container">
<div class="log-line log-info">[INFO] 系统监控页面已加载</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
let performanceChart;
let autoRefreshInterval;
let isAutoRefreshEnabled = false;
$(document).ready(function() {
initPerformanceChart();
loadSystemMetrics();
checkServices();
loadFlashSaleStats();
loadSystemLogs();
});
function initPerformanceChart() {
const ctx = document.getElementById('performance-chart').getContext('2d');
performanceChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'CPU使用率',
data: [],
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.1
}, {
label: '内存使用率',
data: [],
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
},
plugins: {
legend: {
position: 'top',
}
}
}
});
}
function loadSystemMetrics() {
$.get('/api/admin/system/metrics', function(data) {
$('#cpu-usage').text(data.cpuUsage + '%');
$('#memory-usage').text(data.memoryUsage + '%');
$('#active-users').text(data.activeUsers);
$('#total-requests').text(data.totalRequests.toLocaleString());
// 更新图表
updatePerformanceChart(data.cpuUsage, data.memoryUsage);
addLogEntry('info', '系统指标已更新');
}).fail(function() {
// 模拟数据
const mockData = {
cpuUsage: Math.floor(Math.random() * 80) + 10,
memoryUsage: Math.floor(Math.random() * 70) + 20,
activeUsers: Math.floor(Math.random() * 100) + 50,
totalRequests: Math.floor(Math.random() * 10000) + 5000
};
$('#cpu-usage').text(mockData.cpuUsage + '%');
$('#memory-usage').text(mockData.memoryUsage + '%');
$('#active-users').text(mockData.activeUsers);
$('#total-requests').text(mockData.totalRequests.toLocaleString());
updatePerformanceChart(mockData.cpuUsage, mockData.memoryUsage);
addLogEntry('warn', '无法连接到监控API显示模拟数据');
});
}
function updatePerformanceChart(cpuUsage, memoryUsage) {
const now = new Date().toLocaleTimeString();
performanceChart.data.labels.push(now);
performanceChart.data.datasets[0].data.push(cpuUsage);
performanceChart.data.datasets[1].data.push(memoryUsage);
// 保持最近20个数据点
if (performanceChart.data.labels.length > 20) {
performanceChart.data.labels.shift();
performanceChart.data.datasets[0].data.shift();
performanceChart.data.datasets[1].data.shift();
}
performanceChart.update();
}
function checkServices() {
// 检查Redis服务
$.get('/api/admin/health/redis').done(function() {
updateServiceStatus('redis', true);
}).fail(function() {
updateServiceStatus('redis', false);
});
// 检查MySQL服务
$.get('/api/admin/health/mysql').done(function() {
updateServiceStatus('mysql', true);
}).fail(function() {
updateServiceStatus('mysql', false);
});
}
function updateServiceStatus(service, isOnline) {
const indicator = $('#' + service + '-indicator');
const status = $('#' + service + '-status');
if (isOnline) {
indicator.removeClass('status-warning status-offline').addClass('status-online');
status.removeClass('bg-warning bg-danger').addClass('bg-success').text('运行中');
addLogEntry('info', service.toUpperCase() + ' 服务状态正常');
} else {
indicator.removeClass('status-online status-warning').addClass('status-offline');
status.removeClass('bg-success bg-warning').addClass('bg-danger').text('离线');
addLogEntry('error', service.toUpperCase() + ' 服务连接失败');
}
}
function loadFlashSaleStats() {
$.get('/api/admin/flashsale/monitor', function(data) {
let html = '<div class="row">';
if (data && data.length > 0) {
data.forEach(function(flashSale) {
const progressPercentage = Math.max(0, (flashSale.flashStock - flashSale.remainingStock) / flashSale.flashStock * 100);
html += `
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h6 class="card-title">${flashSale.productName}</h6>
<div class="d-flex justify-content-between mb-2">
<small>已售:${flashSale.flashStock - flashSale.remainingStock}</small>
<small>剩余:${flashSale.remainingStock}</small>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar ${progressPercentage > 80 ? 'bg-warning' : 'bg-success'}"
style="width: ${progressPercentage}%"></div>
</div>
<small class="text-muted mt-1 d-block">状态: ${flashSale.statusDescription}</small>
</div>
</div>
</div>
`;
});
} else {
html += '<div class="col-12"><p class="text-muted text-center">暂无活跃的秒杀活动</p></div>';
}
html += '</div>';
$('#flashsale-monitor').html(html);
addLogEntry('info', '秒杀活动监控数据已更新');
}).fail(function() {
$('#flashsale-monitor').html('<div class="text-center py-4"><p class="text-danger">加载失败</p></div>');
addLogEntry('error', '加载秒杀监控数据失败');
});
}
function loadSystemLogs() {
// 模拟实时日志
const logTypes = ['info', 'warn', 'error', 'debug'];
const logMessages = [
'用户登录成功',
'秒杀活动开始',
'Redis连接池满',
'数据库查询耗时较长',
'缓存命中率下降',
'用户注册完成',
'订单支付成功',
'库存更新完成'
];
// 添加一些初始日志
setTimeout(() => {
for (let i = 0; i < 5; i++) {
const type = logTypes[Math.floor(Math.random() * logTypes.length)];
const message = logMessages[Math.floor(Math.random() * logMessages.length)];
addLogEntry(type, message);
}
}, 1000);
}
function addLogEntry(level, message) {
const timestamp = new Date().toLocaleTimeString();
const logLine = `<div class="log-line log-${level}">[${timestamp}] [${level.toUpperCase()}] ${message}</div>`;
$('#log-container').prepend(logLine);
// 保持最多100条日志
const logLines = $('#log-container .log-line');
if (logLines.length > 100) {
logLines.slice(100).remove();
}
}
function refreshAll() {
loadSystemMetrics();
checkServices();
loadFlashSaleStats();
addLogEntry('info', '手动刷新所有监控数据');
}
function clearLogs() {
$('#log-container').empty();
addLogEntry('info', '日志已清空');
}
function toggleAutoRefresh() {
if (isAutoRefreshEnabled) {
clearInterval(autoRefreshInterval);
$('#auto-refresh-icon').removeClass('fa-stop').addClass('fa-play');
$('#auto-refresh-text').text('自动刷新');
isAutoRefreshEnabled = false;
addLogEntry('info', '自动刷新已停止');
} else {
autoRefreshInterval = setInterval(function() {
loadSystemMetrics();
checkServices();
loadFlashSaleStats();
}, 10000); // 每10秒刷新
$('#auto-refresh-icon').removeClass('fa-play').addClass('fa-stop');
$('#auto-refresh-text').text('停止刷新');
isAutoRefreshEnabled = true;
addLogEntry('info', '自动刷新已启动');
}
}
// 页面离开时清理定时器
$(window).on('beforeunload', function() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,383 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>商品详情 - 秒杀系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.product-image {
max-width: 100%;
height: 400px;
object-fit: cover;
border-radius: 10px;
}
.price {
color: #e74c3c;
font-size: 2rem;
font-weight: bold;
}
.original-price {
color: #7f8c8d;
text-decoration: line-through;
font-size: 1.2rem;
}
.flash-sale-badge {
background: linear-gradient(45deg, #ff4757, #ff3838);
color: white;
padding: 5px 15px;
border-radius: 20px;
display: inline-block;
margin-bottom: 15px;
}
.stock-info {
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
margin: 20px 0;
}
.action-buttons {
margin-top: 20px;
}
.btn-flash-sale {
background: linear-gradient(45deg, #ff4757, #ff3838);
border: none;
color: white;
font-weight: bold;
padding: 12px 30px;
border-radius: 25px;
}
.btn-flash-sale:hover {
background: linear-gradient(45deg, #ff3838, #e84118);
color: white;
}
.countdown-timer {
background: #2c3e50;
color: white;
padding: 15px;
border-radius: 10px;
text-align: center;
margin: 20px 0;
}
.countdown-item {
display: inline-block;
margin: 0 10px;
text-align: center;
}
.countdown-number {
font-size: 2rem;
font-weight: bold;
display: block;
}
.countdown-label {
font-size: 0.8rem;
opacity: 0.8;
}
.flash-sale-ended {
background: #7f8c8d;
color: white;
padding: 15px;
border-radius: 10px;
text-align: center;
margin: 20px 0;
}
</style>
</head>
<body>
<jsp:include page="common/header.jsp" />
<div class="container mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item"><a href="/products">商品列表</a></li>
<li class="breadcrumb-item active" aria-current="page">${product.name}</li>
</ol>
</nav>
<div class="row">
<div class="col-md-6">
<img src="${product.imageUrl != null ? product.imageUrl : '/static/images/default-product.svg'}"
alt="${product.name}" class="product-image">
</div>
<div class="col-md-6">
<h1 class="h2 mb-3">${product.name}</h1>
<c:if test="${flashSale != null}">
<div class="flash-sale-badge">
<i class="fas fa-bolt"></i> 限时秒杀
</div>
<div class="price-section">
<span class="price">¥<fmt:formatNumber value="${flashSale.flashPrice}" pattern="#,##0.00" /></span>
<span class="original-price ms-3">原价:¥<fmt:formatNumber value="${product.price}" pattern="#,##0.00" /></span>
</div>
<c:choose>
<c:when test="${flashSale.statusDescription == '未开始'}">
<div class="countdown-timer">
<h5 class="mb-3">距离秒杀开始还有</h5>
<div id="countdown-container">
<div class="countdown-item">
<span class="countdown-number" id="days">00</span>
<span class="countdown-label">天</span>
</div>
<div class="countdown-item">
<span class="countdown-number" id="hours">00</span>
<span class="countdown-label">时</span>
</div>
<div class="countdown-item">
<span class="countdown-number" id="minutes">00</span>
<span class="countdown-label">分</span>
</div>
<div class="countdown-item">
<span class="countdown-number" id="seconds">00</span>
<span class="countdown-label">秒</span>
</div>
</div>
</div>
</c:when>
<c:when test="${flashSale.statusDescription == '进行中'}">
<div class="countdown-timer">
<h5 class="mb-3">距离秒杀结束还有</h5>
<div id="countdown-container">
<div class="countdown-item">
<span class="countdown-number" id="hours">00</span>
<span class="countdown-label">时</span>
</div>
<div class="countdown-item">
<span class="countdown-number" id="minutes">00</span>
<span class="countdown-label">分</span>
</div>
<div class="countdown-item">
<span class="countdown-number" id="seconds">00</span>
<span class="countdown-label">秒</span>
</div>
</div>
</div>
</c:when>
<c:otherwise>
<div class="flash-sale-ended">
<h5 class="mb-0">秒杀活动已结束</h5>
</div>
</c:otherwise>
</c:choose>
</c:if>
<c:if test="${flashSale == null}">
<div class="price-section">
<span class="price">¥<fmt:formatNumber value="${product.price}" pattern="#,##0.00" /></span>
</div>
</c:if>
<div class="stock-info">
<div class="row">
<div class="col-6">
<strong>库存:</strong>
<c:choose>
<c:when test="${flashSale != null}">
<span class="text-primary">${flashSale.remainingStock} 件</span>
</c:when>
<c:otherwise>
<span class="text-primary">${product.stock} 件</span>
</c:otherwise>
</c:choose>
</div>
<div class="col-6">
<strong>销量:</strong>
<span class="text-info">${product.sales} 件</span>
</div>
</div>
</div>
<div class="action-buttons">
<c:choose>
<c:when test="${flashSale != null && flashSale.canParticipate}">
<button type="button" class="btn btn-flash-sale btn-lg me-3" onclick="participateFlashSale()">
<i class="fas fa-bolt"></i> 立即秒杀
</button>
</c:when>
<c:when test="${flashSale != null}">
<button type="button" class="btn btn-secondary btn-lg me-3" disabled>
秒杀已结束或库存不足
</button>
</c:when>
<c:otherwise>
<div class="input-group mb-3" style="max-width: 150px; display: inline-block;">
<button class="btn btn-outline-secondary" type="button" onclick="decreaseQuantity()">-</button>
<input type="number" class="form-control text-center" id="quantity" value="1" min="1" max="${product.stock}">
<button class="btn btn-outline-secondary" type="button" onclick="increaseQuantity()">+</button>
</div>
<br>
<button type="button" class="btn btn-primary btn-lg me-3" onclick="addToCart()">
<i class="fas fa-shopping-cart"></i> 加入购物车
</button>
<button type="button" class="btn btn-warning btn-lg" onclick="buyNow()">
<i class="fas fa-credit-card"></i> 立即购买
</button>
</c:otherwise>
</c:choose>
<button type="button" class="btn btn-outline-danger ms-2" onclick="toggleFavorite()">
<i class="far fa-heart"></i> 收藏
</button>
</div>
<div class="mt-4">
<h5>商品描述</h5>
<p class="text-muted">${product.description != null ? product.description : '暂无描述'}</p>
</div>
</div>
</div>
</div>
<jsp:include page="common/footer.jsp" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
// 倒计时功能
<c:if test="${flashSale != null && (flashSale.timeToStart > 0 || flashSale.timeToEnd > 0)}">
let countdownTime = ${flashSale.timeToStart > 0 ? flashSale.timeToStart : flashSale.timeToEnd};
function updateCountdown() {
if (countdownTime <= 0) {
location.reload();
return;
}
let days = Math.floor(countdownTime / (1000 * 60 * 60 * 24));
let hours = Math.floor((countdownTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
let minutes = Math.floor((countdownTime % (1000 * 60 * 60)) / (1000 * 60));
let seconds = Math.floor((countdownTime % (1000 * 60)) / 1000);
$('#days').text(String(days).padStart(2, '0'));
$('#hours').text(String(hours).padStart(2, '0'));
$('#minutes').text(String(minutes).padStart(2, '0'));
$('#seconds').text(String(seconds).padStart(2, '0'));
countdownTime -= 1000;
}
setInterval(updateCountdown, 1000);
updateCountdown();
</c:if>
function increaseQuantity() {
let quantityInput = $('#quantity');
let currentValue = parseInt(quantityInput.val());
let maxValue = parseInt(quantityInput.attr('max'));
if (currentValue < maxValue) {
quantityInput.val(currentValue + 1);
}
}
function decreaseQuantity() {
let quantityInput = $('#quantity');
let currentValue = parseInt(quantityInput.val());
if (currentValue > 1) {
quantityInput.val(currentValue - 1);
}
}
function participateFlashSale() {
<c:choose>
<c:when test="${sessionScope.user == null}">
alert('请先登录');
location.href = '/login';
</c:when>
<c:otherwise>
$.ajax({
url: '/api/flashsale/participate',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
flashSaleId: ${flashSale.id},
quantity: 1
}),
success: function(response) {
if (response.success) {
alert('秒杀成功订单ID' + response.orderId);
location.href = '/orders/' + response.orderId;
} else {
alert('秒杀失败:' + response.message);
}
},
error: function() {
alert('秒杀失败,请重试');
}
});
</c:otherwise>
</c:choose>
}
function addToCart() {
<c:choose>
<c:when test="${sessionScope.user == null}">
alert('请先登录');
location.href = '/login';
</c:when>
<c:otherwise>
let quantity = parseInt($('#quantity').val());
$.ajax({
url: '/api/cart/add',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
productId: ${product.id},
quantity: quantity
}),
success: function(response) {
if (response.success) {
alert('商品已添加到购物车');
} else {
alert('添加失败:' + response.message);
}
},
error: function() {
alert('添加失败,请重试');
}
});
</c:otherwise>
</c:choose>
}
function buyNow() {
<c:choose>
<c:when test="${sessionScope.user == null}">
alert('请先登录');
location.href = '/login';
</c:when>
<c:otherwise>
// 实现立即购买逻辑
addToCart();
setTimeout(() => {
location.href = '/cart';
}, 500);
</c:otherwise>
</c:choose>
}
function toggleFavorite() {
<c:choose>
<c:when test="${sessionScope.user == null}">
alert('请先登录');
location.href = '/login';
</c:when>
<c:otherwise>
// 实现收藏功能
alert('收藏功能开发中...');
</c:otherwise>
</c:choose>
}
</script>
</body>
</html>

View File

@@ -0,0 +1,493 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人中心 - 秒杀系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.profile-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 0;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid white;
object-fit: cover;
}
.profile-stats {
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: -50px;
position: relative;
z-index: 10;
}
.stat-item {
text-align: center;
padding: 20px;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
color: #6c757d;
font-size: 0.9rem;
}
.nav-tabs .nav-link {
border: none;
color: #6c757d;
font-weight: 500;
}
.nav-tabs .nav-link.active {
color: #667eea;
border-bottom: 2px solid #667eea;
background: none;
}
.form-floating label {
color: #6c757d;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6b4190 100%);
}
.card {
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.order-item {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.order-status {
padding: 3px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.status-pending { background: #fff3cd; color: #856404; }
.status-paid { background: #d1ecf1; color: #0c5460; }
.status-shipped { background: #d4edda; color: #155724; }
.status-completed { background: #cce5ff; color: #004085; }
.status-cancelled { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<jsp:include page="common/header.jsp" />
<!-- 用户信息头部 -->
<div class="profile-header">
<div class="container">
<div class="row align-items-center">
<div class="col-auto">
<img src="${user.avatar != null ? user.avatar : 'https://via.placeholder.com/120x120'}"
alt="头像" class="avatar">
</div>
<div class="col">
<h2 class="mb-2">${user.username}</h2>
<p class="mb-0 opacity-75">
<i class="fas fa-envelope me-2"></i>${user.email}
<span class="ms-4">
<i class="fas fa-calendar me-2"></i>
加入时间:<fmt:formatDate value="${user.createdAt}" pattern="yyyy年MM月dd日"/>
</span>
</p>
</div>
</div>
</div>
</div>
<div class="container">
<!-- 统计数据 -->
<div class="profile-stats">
<div class="row">
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number" id="totalOrders">0</div>
<div class="stat-label">总订单数</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number" id="totalAmount">¥0.00</div>
<div class="stat-label">累计消费</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number" id="flashSaleSuccess">0</div>
<div class="stat-label">秒杀成功</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number" id="favoriteCount">0</div>
<div class="stat-label">收藏商品</div>
</div>
</div>
</div>
</div>
<!-- 选项卡 -->
<div class="mt-5">
<ul class="nav nav-tabs" id="profileTabs">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile-info">
<i class="fas fa-user me-2"></i>个人信息
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#order-history">
<i class="fas fa-shopping-bag me-2"></i>订单历史
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#flash-sale-history">
<i class="fas fa-bolt me-2"></i>秒杀记录
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#account-settings">
<i class="fas fa-cog me-2"></i>账户设置
</button>
</li>
</ul>
<div class="tab-content mt-4">
<!-- 个人信息 -->
<div class="tab-pane fade show active" id="profile-info">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-user me-2"></i>个人信息
</h5>
<form id="profileForm">
<div class="row">
<div class="col-md-6">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="username"
value="${user.username}" readonly>
<label for="username">用户名</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3">
<input type="email" class="form-control" id="email"
value="${user.email}">
<label for="email">邮箱</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="phone"
value="${user.phone != null ? user.phone : ''}">
<label for="phone">手机号码</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3">
<select class="form-select" id="gender">
<option value="" ${user.gender == null ? 'selected' : ''}>请选择</option>
<option value="male" ${user.gender == 'male' ? 'selected' : ''}>男</option>
<option value="female" ${user.gender == 'female' ? 'selected' : ''}>女</option>
<option value="other" ${user.gender == 'other' ? 'selected' : ''}>其他</option>
</select>
<label for="gender">性别</label>
</div>
</div>
</div>
<div class="form-floating mb-3">
<textarea class="form-control" id="address" style="height: 100px">${user.address != null ? user.address : ''}</textarea>
<label for="address">地址</label>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>保存修改
</button>
</form>
</div>
</div>
</div>
<!-- 订单历史 -->
<div class="tab-pane fade" id="order-history">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-shopping-bag me-2"></i>订单历史
</h5>
<div id="orderHistoryContent">
<div class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
<p class="mt-3 text-muted">加载中...</p>
</div>
</div>
</div>
</div>
</div>
<!-- 秒杀记录 -->
<div class="tab-pane fade" id="flash-sale-history">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-bolt me-2"></i>秒杀记录
</h5>
<div id="flashSaleHistoryContent">
<div class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
<p class="mt-3 text-muted">加载中...</p>
</div>
</div>
</div>
</div>
</div>
<!-- 账户设置 -->
<div class="tab-pane fade" id="account-settings">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-cog me-2"></i>账户设置
</h5>
<form id="passwordForm">
<div class="form-floating mb-3">
<input type="password" class="form-control" id="currentPassword" required>
<label for="currentPassword">当前密码</label>
</div>
<div class="form-floating mb-3">
<input type="password" class="form-control" id="newPassword" required>
<label for="newPassword">新密码</label>
</div>
<div class="form-floating mb-3">
<input type="password" class="form-control" id="confirmPassword" required>
<label for="confirmPassword">确认新密码</label>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-key me-2"></i>修改密码
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<jsp:include page="common/footer.jsp" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).ready(function() {
loadUserStats();
// 选项卡切换事件
$('#profileTabs button[data-bs-toggle="tab"]').on('shown.bs.tab', function(e) {
const target = $(e.target).data('bs-target');
if (target === '#order-history') {
loadOrderHistory();
} else if (target === '#flash-sale-history') {
loadFlashSaleHistory();
}
});
// 个人信息表单提交
$('#profileForm').on('submit', function(e) {
e.preventDefault();
updateProfile();
});
// 密码修改表单提交
$('#passwordForm').on('submit', function(e) {
e.preventDefault();
changePassword();
});
});
function loadUserStats() {
$.get('/api/user/stats', function(data) {
$('#totalOrders').text(data.totalOrders || 0);
$('#totalAmount').text('¥' + (data.totalAmount || 0).toFixed(2));
$('#flashSaleSuccess').text(data.flashSaleSuccess || 0);
$('#favoriteCount').text(data.favoriteCount || 0);
}).fail(function() {
console.error('加载用户统计失败');
});
}
function loadOrderHistory() {
$.get('/api/orders/user', function(data) {
let html = '';
if (data && data.length > 0) {
data.forEach(function(order) {
html += `
<div class="order-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">订单号:${order.orderNumber || order.id}</h6>
<small class="text-muted">
创建时间:${new Date(order.createdAt).toLocaleString()}
</small>
</div>
<div class="text-end">
<span class="order-status status-${getStatusClass(order.status)}">
${getStatusText(order.status)}
</span>
<div class="mt-1">
<strong>¥${order.totalPrice.toFixed(2)}</strong>
</div>
</div>
</div>
<div class="mt-2">
<small class="text-muted">
商品:${order.productName || '商品ID:' + order.productId} × ${order.quantity}
</small>
</div>
</div>
`;
});
} else {
html = '<div class="text-center py-5"><p class="text-muted">暂无订单记录</p></div>';
}
$('#orderHistoryContent').html(html);
}).fail(function() {
$('#orderHistoryContent').html('<div class="text-center py-5"><p class="text-danger">加载订单历史失败</p></div>');
});
}
function loadFlashSaleHistory() {
$.get('/api/orders/user?type=2', function(data) {
let html = '';
if (data && data.length > 0) {
data.forEach(function(order) {
html += `
<div class="order-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">
<i class="fas fa-bolt text-warning me-1"></i>
秒杀订单:${order.orderNumber || order.id}
</h6>
<small class="text-muted">
秒杀时间:${new Date(order.createdAt).toLocaleString()}
</small>
</div>
<div class="text-end">
<span class="order-status status-${getStatusClass(order.status)}">
${getStatusText(order.status)}
</span>
<div class="mt-1">
<strong class="text-danger">¥${order.totalPrice.toFixed(2)}</strong>
</div>
</div>
</div>
</div>
`;
});
} else {
html = '<div class="text-center py-5"><p class="text-muted">暂无秒杀记录</p></div>';
}
$('#flashSaleHistoryContent').html(html);
}).fail(function() {
$('#flashSaleHistoryContent').html('<div class="text-center py-5"><p class="text-danger">加载秒杀记录失败</p></div>');
});
}
function updateProfile() {
const profileData = {
email: $('#email').val(),
phone: $('#phone').val(),
gender: $('#gender').val(),
address: $('#address').val()
};
$.ajax({
url: '/api/user/profile',
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify(profileData),
success: function(response) {
if (response.success) {
alert('个人信息更新成功');
} else {
alert('更新失败:' + response.message);
}
},
error: function() {
alert('更新失败,请重试');
}
});
}
function changePassword() {
const passwordData = {
currentPassword: $('#currentPassword').val(),
newPassword: $('#newPassword').val(),
confirmPassword: $('#confirmPassword').val()
};
if (passwordData.newPassword !== passwordData.confirmPassword) {
alert('新密码和确认密码不匹配');
return;
}
$.ajax({
url: '/api/user/password',
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify(passwordData),
success: function(response) {
if (response.success) {
alert('密码修改成功');
$('#passwordForm')[0].reset();
} else {
alert('修改失败:' + response.message);
}
},
error: function() {
alert('修改失败,请重试');
}
});
}
function getStatusClass(status) {
switch(status) {
case 1: return 'pending'; // 待支付
case 2: return 'paid'; // 已支付
case 3: return 'shipped'; // 已发货
case 4: return 'completed'; // 已完成
case 5: return 'cancelled'; // 已取消
default: return 'pending';
}
}
function getStatusText(status) {
switch(status) {
case 1: return '待支付';
case 2: return '已支付';
case 3: return '已发货';
case 4: return '已完成';
case 5: return '已取消';
default: return '未知状态';
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
package com.org.flashsalesystem.service;
import com.org.flashsalesystem.dto.FlashSaleDTO;
import com.org.flashsalesystem.entity.FlashSale;
import com.org.flashsalesystem.entity.Product;
import com.org.flashsalesystem.repository.FlashSaleRepository;
import com.org.flashsalesystem.repository.OrderRepository;
import com.org.flashsalesystem.repository.ProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 秒杀服务测试类
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("秒杀服务测试")
class FlashSaleServiceTest {
@Mock
private FlashSaleRepository flashSaleRepository;
@Mock
private ProductRepository productRepository;
@Mock
private OrderRepository orderRepository;
@Mock
private RedisService redisService;
@Mock
private RateLimitService rateLimitService;
@InjectMocks
private FlashSaleService flashSaleService;
private Product testProduct;
private FlashSale testFlashSale;
@BeforeEach
void setUp() {
testProduct = new Product();
testProduct.setId(1L);
testProduct.setName("测试商品");
testProduct.setPrice(new BigDecimal("100.00"));
testProduct.setStock(1000);
testProduct.setStatus(1);
testFlashSale = new FlashSale();
testFlashSale.setId(1L);
testFlashSale.setProductId(1L);
testFlashSale.setFlashPrice(new BigDecimal("50.00"));
testFlashSale.setFlashStock(100);
testFlashSale.setStartTime(LocalDateTime.now().plusMinutes(10));
testFlashSale.setEndTime(LocalDateTime.now().plusHours(2));
testFlashSale.setStatus(1);
}
@Test
@DisplayName("创建秒杀活动 - 成功")
void createFlashSale_Success() {
FlashSaleDTO.CreateDTO createDTO = new FlashSaleDTO.CreateDTO();
createDTO.setProductId(1L);
createDTO.setFlashPrice(new BigDecimal("50.00"));
createDTO.setFlashStock(100);
createDTO.setStartTime(LocalDateTime.now().plusMinutes(10));
createDTO.setEndTime(LocalDateTime.now().plusHours(2));
when(productRepository.findById(1L)).thenReturn(Optional.of(testProduct));
when(flashSaleRepository.findByProductId(1L)).thenReturn(Optional.empty());
when(flashSaleRepository.save(any(FlashSale.class))).thenReturn(testFlashSale);
when(redisService.getString(anyString())).thenReturn("100");
FlashSaleDTO result = flashSaleService.createFlashSale(createDTO);
assertNotNull(result);
assertEquals(1L, result.getId());
verify(productRepository).findById(1L);
verify(flashSaleRepository).save(any(FlashSale.class));
}
@Test
@DisplayName("创建秒杀活动 - 商品不存在")
void createFlashSale_ProductNotFound() {
FlashSaleDTO.CreateDTO createDTO = new FlashSaleDTO.CreateDTO();
createDTO.setProductId(999L);
when(productRepository.findById(999L)).thenReturn(Optional.empty());
RuntimeException exception = assertThrows(RuntimeException.class,
() -> flashSaleService.createFlashSale(createDTO));
assertEquals("商品不存在", exception.getMessage());
verify(productRepository).findById(999L);
verifyNoMoreInteractions(flashSaleRepository);
}
@Test
@DisplayName("获取秒杀库存")
void getFlashSaleStock_Success() {
when(redisService.get(anyString())).thenReturn(50);
Integer stock = flashSaleService.getFlashSaleStock(1L);
assertNotNull(stock);
assertEquals(50, stock);
verify(redisService).get(anyString());
}
@Test
@DisplayName("获取秒杀库存 - 无库存数据")
void getFlashSaleStock_NoData() {
when(redisService.get(anyString())).thenReturn(null);
Integer stock = flashSaleService.getFlashSaleStock(1L);
assertEquals(0, stock);
}
@Test
@DisplayName("预热秒杀活动")
void preloadFlashSale_Success() {
when(flashSaleRepository.findById(1L)).thenReturn(Optional.of(testFlashSale));
when(productRepository.findById(1L)).thenReturn(Optional.of(testProduct));
when(redisService.getString(anyString())).thenReturn("100");
assertDoesNotThrow(() -> flashSaleService.preloadFlashSale(1L));
verify(flashSaleRepository).findById(1L);
verify(productRepository).findById(1L);
verify(redisService).setString(anyString(), anyString());
}
@Test
@DisplayName("预热秒杀活动 - 活动不存在")
void preloadFlashSale_ActivityNotFound() {
when(flashSaleRepository.findById(999L)).thenReturn(Optional.empty());
assertDoesNotThrow(() -> flashSaleService.preloadFlashSale(999L));
verify(flashSaleRepository).findById(999L);
verifyNoMoreInteractions(redisService);
}
}