后端功能增强:全局异常处理、API控制器、JSP视图和单元测试
- 添加 GlobalExceptionHandler 全局异常处理 - 添加 ApiController REST API 控制器 - 更新 WebConfig 跨域配置和 ProductRepository 查询方法 - 新增 monitor/product-detail/profile JSP 视图页面 - 添加 FlashSaleServiceTest 秒杀服务单元测试 - 更新 application.yml 配置
This commit is contained in:
7
flash-sale-frontend/.env.development
Normal file
7
flash-sale-frontend/.env.development
Normal 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
|
||||
7
flash-sale-frontend/.env.production
Normal file
7
flash-sale-frontend/.env.production
Normal 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
|
||||
1
flash-sale-frontend/.npmrc
Normal file
1
flash-sale-frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
cache=/tmp/npm-cache
|
||||
189
flash-sale-frontend/README.md
Normal file
189
flash-sale-frontend/README.md
Normal 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
|
||||
15
flash-sale-frontend/index.html
Normal file
15
flash-sale-frontend/index.html
Normal 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
5602
flash-sale-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
flash-sale-frontend/package.json
Normal file
48
flash-sale-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
flash-sale-frontend/postcss.config.js
Normal file
6
flash-sale-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
26
flash-sale-frontend/src/App.vue
Normal file
26
flash-sale-frontend/src/App.vue
Normal 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>
|
||||
42
flash-sale-frontend/src/api/modules/cart.ts
Normal file
42
flash-sale-frontend/src/api/modules/cart.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
42
flash-sale-frontend/src/api/modules/flashsale.ts
Normal file
42
flash-sale-frontend/src/api/modules/flashsale.ts
Normal 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`)
|
||||
},
|
||||
}
|
||||
57
flash-sale-frontend/src/api/modules/order.ts
Normal file
57
flash-sale-frontend/src/api/modules/order.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
34
flash-sale-frontend/src/api/modules/product.ts
Normal file
34
flash-sale-frontend/src/api/modules/product.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
34
flash-sale-frontend/src/api/modules/user.ts
Normal file
34
flash-sale-frontend/src/api/modules/user.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
118
flash-sale-frontend/src/api/request.ts
Normal file
118
flash-sale-frontend/src/api/request.ts
Normal 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
|
||||
71
flash-sale-frontend/src/components/business/CountDown.vue
Normal file
71
flash-sale-frontend/src/components/business/CountDown.vue
Normal 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>
|
||||
179
flash-sale-frontend/src/components/business/FlashSaleCard.vue
Normal file
179
flash-sale-frontend/src/components/business/FlashSaleCard.vue
Normal 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>
|
||||
107
flash-sale-frontend/src/components/business/ProductCard.vue
Normal file
107
flash-sale-frontend/src/components/business/ProductCard.vue
Normal 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>
|
||||
88
flash-sale-frontend/src/components/common/AppFooter.vue
Normal file
88
flash-sale-frontend/src/components/common/AppFooter.vue
Normal 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>© 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>
|
||||
186
flash-sale-frontend/src/components/common/AppHeader.vue
Normal file
186
flash-sale-frontend/src/components/common/AppHeader.vue
Normal 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>
|
||||
339
flash-sale-frontend/src/components/common/ImageUpload.vue
Normal file
339
flash-sale-frontend/src/components/common/ImageUpload.vue
Normal 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>
|
||||
442
flash-sale-frontend/src/components/common/NotificationCenter.vue
Normal file
442
flash-sale-frontend/src/components/common/NotificationCenter.vue
Normal 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>
|
||||
443
flash-sale-frontend/src/components/common/SearchComponent.vue
Normal file
443
flash-sale-frontend/src/components/common/SearchComponent.vue
Normal 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>
|
||||
219
flash-sale-frontend/src/composables/useWebSocket.ts
Normal file
219
flash-sale-frontend/src/composables/useWebSocket.ts
Normal 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
|
||||
}
|
||||
}
|
||||
306
flash-sale-frontend/src/layouts/AdminLayout.vue
Normal file
306
flash-sale-frontend/src/layouts/AdminLayout.vue
Normal 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>
|
||||
48
flash-sale-frontend/src/layouts/MainLayout.vue
Normal file
48
flash-sale-frontend/src/layouts/MainLayout.vue
Normal 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>
|
||||
27
flash-sale-frontend/src/main.ts
Normal file
27
flash-sale-frontend/src/main.ts
Normal 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')
|
||||
382
flash-sale-frontend/src/pages/admin/dashboard.vue
Normal file
382
flash-sale-frontend/src/pages/admin/dashboard.vue
Normal 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>
|
||||
48
flash-sale-frontend/src/pages/admin/flashsales.vue
Normal file
48
flash-sale-frontend/src/pages/admin/flashsales.vue
Normal 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>
|
||||
41
flash-sale-frontend/src/pages/admin/orders.vue
Normal file
41
flash-sale-frontend/src/pages/admin/orders.vue
Normal 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>
|
||||
46
flash-sale-frontend/src/pages/admin/products.vue
Normal file
46
flash-sale-frontend/src/pages/admin/products.vue
Normal 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>
|
||||
42
flash-sale-frontend/src/pages/admin/users.vue
Normal file
42
flash-sale-frontend/src/pages/admin/users.vue
Normal 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>
|
||||
342
flash-sale-frontend/src/pages/cart/index.vue
Normal file
342
flash-sale-frontend/src/pages/cart/index.vue
Normal 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>
|
||||
39
flash-sale-frontend/src/pages/error/404.vue
Normal file
39
flash-sale-frontend/src/pages/error/404.vue
Normal 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>
|
||||
334
flash-sale-frontend/src/pages/flashsale/detail.vue
Normal file
334
flash-sale-frontend/src/pages/flashsale/detail.vue
Normal 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>
|
||||
273
flash-sale-frontend/src/pages/flashsale/index.vue
Normal file
273
flash-sale-frontend/src/pages/flashsale/index.vue
Normal 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>
|
||||
260
flash-sale-frontend/src/pages/home/index.vue
Normal file
260
flash-sale-frontend/src/pages/home/index.vue
Normal 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>
|
||||
357
flash-sale-frontend/src/pages/order/detail.vue
Normal file
357
flash-sale-frontend/src/pages/order/detail.vue
Normal 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>
|
||||
407
flash-sale-frontend/src/pages/order/index.vue
Normal file
407
flash-sale-frontend/src/pages/order/index.vue
Normal 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>
|
||||
314
flash-sale-frontend/src/pages/product/detail.vue
Normal file
314
flash-sale-frontend/src/pages/product/detail.vue
Normal 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>
|
||||
227
flash-sale-frontend/src/pages/product/index.vue
Normal file
227
flash-sale-frontend/src/pages/product/index.vue
Normal 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>
|
||||
165
flash-sale-frontend/src/pages/user/login.vue
Normal file
165
flash-sale-frontend/src/pages/user/login.vue
Normal 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>
|
||||
309
flash-sale-frontend/src/pages/user/profile.vue
Normal file
309
flash-sale-frontend/src/pages/user/profile.vue
Normal 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>
|
||||
197
flash-sale-frontend/src/pages/user/register.vue
Normal file
197
flash-sale-frontend/src/pages/user/register.vue
Normal 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>
|
||||
44
flash-sale-frontend/src/router/guards.ts
Normal file
44
flash-sale-frontend/src/router/guards.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
139
flash-sale-frontend/src/router/index.ts
Normal file
139
flash-sale-frontend/src/router/index.ts
Normal 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
|
||||
167
flash-sale-frontend/src/stores/cart.ts
Normal file
167
flash-sale-frontend/src/stores/cart.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
128
flash-sale-frontend/src/stores/user.ts
Normal file
128
flash-sale-frontend/src/stores/user.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
94
flash-sale-frontend/src/styles/index.scss
Normal file
94
flash-sale-frontend/src/styles/index.scss
Normal 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
158
flash-sale-frontend/src/types/api.d.ts
vendored
Normal 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
|
||||
}
|
||||
29
flash-sale-frontend/tailwind.config.js
Normal file
29
flash-sale-frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
26
flash-sale-frontend/tsconfig.json
Normal file
26
flash-sale-frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
flash-sale-frontend/tsconfig.node.json
Normal file
10
flash-sale-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
35
flash-sale-frontend/vite.config.ts
Normal file
35
flash-sale-frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -81,4 +81,9 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
|
||||
* 统计库存小于指定数量的商品数量
|
||||
*/
|
||||
long countByStockLessThan(Integer stock);
|
||||
|
||||
/**
|
||||
* 根据名称模糊查询(忽略大小写)
|
||||
*/
|
||||
Page<Product> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
515
src/main/webapp/WEB-INF/views/admin/monitor.jsp
Normal file
515
src/main/webapp/WEB-INF/views/admin/monitor.jsp
Normal 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>
|
||||
383
src/main/webapp/WEB-INF/views/product-detail.jsp
Normal file
383
src/main/webapp/WEB-INF/views/product-detail.jsp
Normal 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>
|
||||
493
src/main/webapp/WEB-INF/views/profile.jsp
Normal file
493
src/main/webapp/WEB-INF/views/profile.jsp
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user