清除数据
This commit is contained in:
30
CLAUDE.md
30
CLAUDE.md
@@ -4,7 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
FlashSaleSystem 是一个基于 Spring Boot 2.7.6 和 Redis 集群构建的社区生鲜团购系统。系统采用分布式架构设计,通过 Redis 集群实现高并发处理,使用 Lua 脚本保证原子性操作,采用分布式锁防止超卖。
|
CommunityFreshGroupBuySystem 是一个基于 Spring Boot 2.7.6 和 Redis 集群构建的社区生鲜团购系统。系统采用分布式架构设计,通过
|
||||||
|
Redis 集群实现高并发处理,使用 Lua 脚本保证原子性操作,采用分布式锁防止超卖。
|
||||||
|
|
||||||
## 核心架构
|
## 核心架构
|
||||||
|
|
||||||
@@ -54,8 +55,8 @@ mvn spring-boot:run -Dspring.profiles.active=dev # 开发环境
|
|||||||
mvn spring-boot:run -Dspring.profiles.active=cluster # 集群环境
|
mvn spring-boot:run -Dspring.profiles.active=cluster # 集群环境
|
||||||
|
|
||||||
# 运行JAR包
|
# 运行JAR包
|
||||||
java -jar target/FlashSaleSystem-0.0.1-SNAPSHOT.jar
|
java -jar target/CommunityFreshGroupBuySystem-0.0.1-SNAPSHOT.jar
|
||||||
java -jar target/FlashSaleSystem-0.0.1-SNAPSHOT.jar --spring.profiles.active=cluster
|
java -jar target/CommunityFreshGroupBuySystem-0.0.1-SNAPSHOT.jar --spring.profiles.active=cluster
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数据库初始化
|
### 数据库初始化
|
||||||
@@ -84,13 +85,14 @@ mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql
|
|||||||
- **String**: 分布式锁、会话存储、库存计数
|
- **String**: 分布式锁、会话存储、库存计数
|
||||||
- **Hash**: 用户信息、商品信息、购物车数据
|
- **Hash**: 用户信息、商品信息、购物车数据
|
||||||
- **List**: 订单队列、消息队列
|
- **List**: 订单队列、消息队列
|
||||||
- **Set**: 秒杀成功用户集合
|
- **Set**: 限时活动成功用户集合
|
||||||
- **ZSet**: 商品排行榜、热门活动
|
- **ZSet**: 商品排行榜、热门活动
|
||||||
|
|
||||||
### Key前缀规范
|
### Key前缀规范
|
||||||
- `flashsale:` - 秒杀活动数据
|
|
||||||
|
- `flashsale:` - 限时活动数据
|
||||||
- `flashsale_stock:` - 实时库存信息
|
- `flashsale_stock:` - 实时库存信息
|
||||||
- `flashsale_lock:` - 秒杀分布式锁
|
- `flashsale_lock:` - 限时活动分布式锁
|
||||||
- `flashsale_success:` - 成功用户集合
|
- `flashsale_success:` - 成功用户集合
|
||||||
- `user:` - 用户信息缓存
|
- `user:` - 用户信息缓存
|
||||||
- `product:` - 商品信息缓存
|
- `product:` - 商品信息缓存
|
||||||
@@ -106,16 +108,17 @@ mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql
|
|||||||
|
|
||||||
## 核心业务流程
|
## 核心业务流程
|
||||||
|
|
||||||
### 秒杀流程
|
### 限时活动流程
|
||||||
1. **库存预热**: 活动前30分钟通过 `FlashSaleService.preloadStock()` 预加载库存到Redis
|
1. **库存预热**: 活动前30分钟通过 `FlashSaleService.preloadStock()` 预加载库存到Redis
|
||||||
2. **限流检查**: `RateLimitService.checkFlashSaleRateLimit()` 检查用户请求频率(10次/分钟)
|
2. **限流检查**: `RateLimitService.checkFlashSaleRateLimit()` 检查用户请求频率(10次/分钟)
|
||||||
3. **分布式锁**: `RedissonLockService.tryLock()` 获取分布式锁,防止并发超卖
|
3. **分布式锁**: `RedissonLockService.tryLock()` 获取分布式锁,防止并发超卖
|
||||||
4. **库存扣减**: 执行 `flashsale.lua` 脚本原子性扣减库存
|
4. **库存扣减**: 执行 `flashsale.lua` 脚本原子性扣减库存
|
||||||
5. **订单创建**: 数据库创建订单,Redis记录成功用户
|
5. **订单创建**: 数据库创建订单,Redis记录成功用户
|
||||||
6. **消息发布**: 通过Pub/Sub发布秒杀结果通知
|
6. **消息发布**: 通过Pub/Sub发布限时活动结果通知
|
||||||
|
|
||||||
### 关键服务类
|
### 关键服务类
|
||||||
- **FlashSaleService**: 秒杀核心逻辑,包含库存预热、分布式锁、Lua脚本执行
|
|
||||||
|
- **FlashSaleService**: 限时活动核心逻辑,包含库存预热、分布式锁、Lua脚本执行
|
||||||
- **RedisService**: Redis基础操作封装,支持各种数据类型和TTL设置
|
- **RedisService**: Redis基础操作封装,支持各种数据类型和TTL设置
|
||||||
- **RedissonLockService**: Redisson分布式锁实现,支持自动续期
|
- **RedissonLockService**: Redisson分布式锁实现,支持自动续期
|
||||||
- **RateLimitService**: 基于Redis的滑动窗口限流
|
- **RateLimitService**: 基于Redis的滑动窗口限流
|
||||||
@@ -135,7 +138,7 @@ mysql -u root -p flash_sale_db < src/main/resources/sql/test-data.sql
|
|||||||
spring.redis.host: localhost # 单节点配置
|
spring.redis.host: localhost # 单节点配置
|
||||||
spring.redis.cluster.nodes: ... # 集群配置(取消注释启用)
|
spring.redis.cluster.nodes: ... # 集群配置(取消注释启用)
|
||||||
|
|
||||||
# 秒杀业务配置
|
# 限时活动业务配置
|
||||||
flashsale.seckill.rate-limit.max-requests-per-minute: 10 # 限流
|
flashsale.seckill.rate-limit.max-requests-per-minute: 10 # 限流
|
||||||
flashsale.seckill.max-quantity-per-user: 1 # 每人限购
|
flashsale.seckill.max-quantity-per-user: 1 # 每人限购
|
||||||
flashsale.seckill.stock-preload.advance-minutes: 30 # 预热时间
|
flashsale.seckill.stock-preload.advance-minutes: 30 # 预热时间
|
||||||
@@ -143,7 +146,7 @@ flashsale.seckill.stock-preload.advance-minutes: 30 # 预热时间
|
|||||||
# 缓存过期时间
|
# 缓存过期时间
|
||||||
flashsale.cache.user-expire-minutes: 30 # 用户信息
|
flashsale.cache.user-expire-minutes: 30 # 用户信息
|
||||||
flashsale.cache.product-expire-minutes: 60 # 商品信息
|
flashsale.cache.product-expire-minutes: 60 # 商品信息
|
||||||
flashsale.cache.flashsale-expire-minutes: 10 # 秒杀活动
|
flashsale.cache.flashsale-expire-minutes: 10 # 限时活动
|
||||||
```
|
```
|
||||||
|
|
||||||
## 性能优化策略
|
## 性能优化策略
|
||||||
@@ -163,7 +166,8 @@ flashsale.cache.flashsale-expire-minutes: 10 # 秒杀活动
|
|||||||
- **Prometheus监控**: http://localhost:8080/actuator/prometheus
|
- **Prometheus监控**: http://localhost:8080/actuator/prometheus
|
||||||
|
|
||||||
### 日志配置
|
### 日志配置
|
||||||
- **日志文件**: logs/flash-sale-system.log
|
|
||||||
|
- **日志文件**: logs/community-fresh-group-buy-system.log
|
||||||
- **日志级别**: DEBUG (com.org.flashsalesystem, Redis, SQL)
|
- **日志级别**: DEBUG (com.org.flashsalesystem, Redis, SQL)
|
||||||
|
|
||||||
## 测试策略
|
## 测试策略
|
||||||
@@ -175,7 +179,7 @@ flashsale.cache.flashsale-expire-minutes: 10 # 秒杀活动
|
|||||||
|
|
||||||
### 性能测试
|
### 性能测试
|
||||||
- 推荐工具: JMeter, Gatling
|
- 推荐工具: JMeter, Gatling
|
||||||
- 测试场景: 高并发秒杀、库存扣减准确性、分布式锁效果
|
- 测试场景: 高并发限时活动、库存扣减准确性、分布式锁效果
|
||||||
|
|
||||||
## 安全考虑
|
## 安全考虑
|
||||||
|
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -1,14 +1,11 @@
|
|||||||
# 社区生鲜团购系统 (FlashSaleSystem)
|
# 社区生鲜团购系统 (CommunityFreshGroupBuySystem)
|
||||||
|
|
||||||
退货
|
|
||||||
评价
|
|
||||||
|
|
||||||
基于 Spring Boot + Redis 构建的社区生鲜团购系统
|
基于 Spring Boot + Redis 构建的社区生鲜团购系统
|
||||||
|
|
||||||
## 📖 项目概述
|
## 📖 项目概述
|
||||||
|
|
||||||
本项目是一个完整的社区生鲜团购系统,采用分布式架构设计,通过 Redis
|
本项目是一个完整的社区生鲜团购系统,采用分布式架构设计,通过 Redis
|
||||||
集群实现高并发处理能力。系统支持用户管理、商品管理、购物车功能和秒杀活动,具备防超卖、限流控制、分布式锁等核心技术特性。
|
集群实现高并发处理能力。系统支持用户管理、商品管理、购物车、拼团活动和订单管理,具备防超卖、限流控制、分布式锁等核心技术特性。
|
||||||
|
|
||||||
**项目完成度**: 90%
|
**项目完成度**: 90%
|
||||||
|
|
||||||
@@ -34,9 +31,9 @@
|
|||||||
## 🏗️ 项目结构
|
## 🏗️ 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
FlashSaleSystem/
|
CommunityFreshGroupBuySystem/
|
||||||
├── src/main/java/com/org/flashsalesystem/
|
├── src/main/java/com/org/flashsalesystem/
|
||||||
│ ├── FlashSaleSystemApplication.java # 启动类
|
│ ├── CommunityFreshGroupBuySystemApplication.java # 启动类
|
||||||
│ ├── config/ # 配置类
|
│ ├── config/ # 配置类
|
||||||
│ │ ├── RedissonConfig.java # Redis集群配置
|
│ │ ├── RedissonConfig.java # Redis集群配置
|
||||||
│ │ ├── SwaggerConfig.java # API文档配置
|
│ │ ├── SwaggerConfig.java # API文档配置
|
||||||
@@ -44,7 +41,7 @@ FlashSaleSystem/
|
|||||||
│ ├── controller/ # 控制器层
|
│ ├── controller/ # 控制器层
|
||||||
│ │ ├── AdminController.java # 管理员控制器
|
│ │ ├── AdminController.java # 管理员控制器
|
||||||
│ │ ├── CartController.java # 购物车控制器
|
│ │ ├── CartController.java # 购物车控制器
|
||||||
│ │ ├── FlashSaleController.java # 秒杀控制器
|
│ │ ├── FlashSaleController.java # 限时活动控制器
|
||||||
│ │ ├── OrderController.java # 订单控制器
|
│ │ ├── OrderController.java # 订单控制器
|
||||||
│ │ ├── PageController.java # 页面控制器
|
│ │ ├── PageController.java # 页面控制器
|
||||||
│ │ ├── ProductController.java # 商品控制器
|
│ │ ├── ProductController.java # 商品控制器
|
||||||
@@ -52,17 +49,17 @@ FlashSaleSystem/
|
|||||||
│ │ └── UserController.java # 用户控制器
|
│ │ └── UserController.java # 用户控制器
|
||||||
│ ├── dto/ # 数据传输对象
|
│ ├── dto/ # 数据传输对象
|
||||||
│ │ ├── CartDTO.java # 购物车DTO
|
│ │ ├── CartDTO.java # 购物车DTO
|
||||||
│ │ ├── FlashSaleDTO.java # 秒杀DTO
|
│ │ ├── FlashSaleDTO.java # 限时活动DTO
|
||||||
│ │ ├── OrderDTO.java # 订单DTO
|
│ │ ├── OrderDTO.java # 订单DTO
|
||||||
│ │ ├── ProductDTO.java # 商品DTO
|
│ │ ├── ProductDTO.java # 商品DTO
|
||||||
│ │ └── UserDTO.java # 用户DTO
|
│ │ └── UserDTO.java # 用户DTO
|
||||||
│ ├── entity/ # 实体类
|
│ ├── entity/ # 实体类
|
||||||
│ │ ├── FlashSale.java # 秒杀实体
|
│ │ ├── FlashSale.java # 限时活动实体
|
||||||
│ │ ├── Order.java # 订单实体
|
│ │ ├── Order.java # 订单实体
|
||||||
│ │ ├── Product.java # 商品实体
|
│ │ ├── Product.java # 商品实体
|
||||||
│ │ └── User.java # 用户实体
|
│ │ └── User.java # 用户实体
|
||||||
│ ├── repository/ # 数据访问层
|
│ ├── repository/ # 数据访问层
|
||||||
│ │ ├── FlashSaleRepository.java # 秒杀数据访问
|
│ │ ├── FlashSaleRepository.java # 限时活动数据访问
|
||||||
│ │ ├── OrderRepository.java # 订单数据访问
|
│ │ ├── OrderRepository.java # 订单数据访问
|
||||||
│ │ ├── ProductRepository.java # 商品数据访问
|
│ │ ├── ProductRepository.java # 商品数据访问
|
||||||
│ │ └── UserRepository.java # 用户数据访问
|
│ │ └── UserRepository.java # 用户数据访问
|
||||||
@@ -70,7 +67,7 @@ FlashSaleSystem/
|
|||||||
│ │ ├── AdminService.java # 管理员服务
|
│ │ ├── AdminService.java # 管理员服务
|
||||||
│ │ ├── CartService.java # 购物车服务
|
│ │ ├── CartService.java # 购物车服务
|
||||||
│ │ ├── DistributedLockService.java # 分布式锁服务
|
│ │ ├── DistributedLockService.java # 分布式锁服务
|
||||||
│ │ ├── FlashSaleService.java # 秒杀服务
|
│ │ ├── FlashSaleService.java # 限时活动服务
|
||||||
│ │ ├── MessageListenerService.java # 消息监听服务
|
│ │ ├── MessageListenerService.java # 消息监听服务
|
||||||
│ │ ├── OrderService.java # 订单服务
|
│ │ ├── OrderService.java # 订单服务
|
||||||
│ │ ├── ProductService.java # 商品服务
|
│ │ ├── ProductService.java # 商品服务
|
||||||
@@ -88,7 +85,7 @@ FlashSaleSystem/
|
|||||||
│ ├── lua/ # Lua脚本
|
│ ├── lua/ # Lua脚本
|
||||||
│ │ ├── cart_operation.lua # 购物车操作脚本
|
│ │ ├── cart_operation.lua # 购物车操作脚本
|
||||||
│ │ ├── distributed_lock.lua # 分布式锁脚本
|
│ │ ├── distributed_lock.lua # 分布式锁脚本
|
||||||
│ │ ├── flashsale.lua # 秒杀脚本
|
│ │ ├── flashsale.lua # 活动脚本
|
||||||
│ │ ├── rate_limit.lua # 限流脚本
|
│ │ ├── rate_limit.lua # 限流脚本
|
||||||
│ │ └── unlock.lua # 解锁脚本
|
│ │ └── unlock.lua # 解锁脚本
|
||||||
│ ├── sql/ # SQL脚本
|
│ ├── sql/ # SQL脚本
|
||||||
@@ -128,7 +125,7 @@ FlashSaleSystem/
|
|||||||
- 批量操作支持
|
- 批量操作支持
|
||||||
- 持久化策略
|
- 持久化策略
|
||||||
|
|
||||||
### 4. 秒杀核心模块
|
### 4. 限时活动核心模块
|
||||||
|
|
||||||
- 分布式锁防超卖
|
- 分布式锁防超卖
|
||||||
- Lua 脚本原子操作
|
- Lua 脚本原子操作
|
||||||
@@ -154,7 +151,7 @@ FlashSaleSystem/
|
|||||||
|
|
||||||
### Lua 脚本实现
|
### Lua 脚本实现
|
||||||
|
|
||||||
- **秒杀脚本**: 原子性库存扣减,防止超卖
|
- **活动脚本**: 原子性库存扣减,防止超卖
|
||||||
- **分布式锁脚本**: 原子性设置锁和过期时间
|
- **分布式锁脚本**: 原子性设置锁和过期时间
|
||||||
- **限流脚本**: 滑动窗口精确限流
|
- **限流脚本**: 滑动窗口精确限流
|
||||||
- **购物车脚本**: 原子性购物车操作
|
- **购物车脚本**: 原子性购物车操作
|
||||||
@@ -164,7 +161,7 @@ FlashSaleSystem/
|
|||||||
|
|
||||||
- 订单状态变更通知
|
- 订单状态变更通知
|
||||||
- 库存变化通知
|
- 库存变化通知
|
||||||
- 秒杀结果通知
|
- 限时活动结果通知
|
||||||
- 用户行为监听
|
- 用户行为监听
|
||||||
|
|
||||||
### 性能优化技术
|
### 性能优化技术
|
||||||
@@ -174,19 +171,19 @@ FlashSaleSystem/
|
|||||||
- 数据预热机制
|
- 数据预热机制
|
||||||
- 连接池优化
|
- 连接池优化
|
||||||
|
|
||||||
## 🚀 秒杀流程
|
## 🚀 限时活动流程
|
||||||
|
|
||||||
1. **库存预热**: 将商品库存加载到 Redis
|
1. **库存预热**: 将商品库存加载到 Redis
|
||||||
2. **用户请求**: 前端发起秒杀请求
|
2. **用户请求**: 前端发起限时活动请求
|
||||||
3. **限流检查**: 检查用户请求频率
|
3. **限流检查**: 检查用户请求频率
|
||||||
4. **分布式锁**: 获取商品锁防止并发
|
4. **分布式锁**: 获取商品锁防止并发
|
||||||
5. **库存扣减**: Lua 脚本原子性扣减
|
5. **库存扣减**: Lua 脚本原子性扣减
|
||||||
6. **订单创建**: 创建秒杀订单
|
6. **订单创建**: 创建限时订单
|
||||||
7. **消息通知**: 发布秒杀结果消息
|
7. **消息通知**: 发布限时活动结果消息
|
||||||
|
|
||||||
## 🛡️ 防超卖机制
|
## 🛡️ 防超卖机制
|
||||||
|
|
||||||
- **分布式锁**: 串行化处理秒杀请求
|
- **分布式锁**: 串行化处理限时活动请求
|
||||||
- **Lua 脚本**: 原子性检查和扣减库存
|
- **Lua 脚本**: 原子性检查和扣减库存
|
||||||
- **重复检查**: 防止用户重复参与
|
- **重复检查**: 防止用户重复参与
|
||||||
- **数据一致性**: Redis 和数据库双重保障
|
- **数据一致性**: Redis 和数据库双重保障
|
||||||
@@ -221,7 +218,7 @@ FlashSaleSystem/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd FlashSaleSystem
|
cd CommunityFreshGroupBuySystem
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **配置数据库**
|
2. **配置数据库**
|
||||||
@@ -275,7 +272,7 @@ mvn test -Dtest=RedisServiceTest
|
|||||||
### 压力测试
|
### 压力测试
|
||||||
|
|
||||||
- 使用 JMeter 或其他工具进行压力测试
|
- 使用 JMeter 或其他工具进行压力测试
|
||||||
- 测试并发秒杀场景
|
- 测试并发限时活动场景
|
||||||
- 验证系统性能指标
|
- 验证系统性能指标
|
||||||
|
|
||||||
## 📈 项目亮点
|
## 📈 项目亮点
|
||||||
@@ -329,4 +326,4 @@ mvn test -Dtest=RedisServiceTest
|
|||||||
|
|
||||||
**项目完成时间**: 2025-06-28
|
**项目完成时间**: 2025-06-28
|
||||||
**总体完成度**: 90%
|
**总体完成度**: 90%
|
||||||
**核心技术**: Redis集群 + Spring Boot + 分布式锁 + Lua脚本
|
**核心技术**: Redis集群 + Spring Boot + 分布式锁 + Lua脚本
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 社区生鲜团购系统前端 (Flash Sale Frontend)
|
# 社区生鲜团购系统前端 (Group Buy Frontend)
|
||||||
|
|
||||||
基于 Vue 3 + Vite + TypeScript 构建的现代化社区生鲜团购系统前端应用。
|
基于 Vue 3 + Vite + TypeScript 构建的现代化社区生鲜团购系统前端应用。
|
||||||
|
|
||||||
@@ -18,19 +18,22 @@
|
|||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
### 用户端功能
|
### 用户端功能
|
||||||
- 🏠 **首页展示**: 轮播图、秒杀活动、热门商品推荐
|
|
||||||
|
- 🏠 **首页展示**: 轮播图、限时活动、热门商品推荐
|
||||||
- 🔐 **用户认证**: 登录、注册、个人中心管理
|
- 🔐 **用户认证**: 登录、注册、个人中心管理
|
||||||
- ⚡ **秒杀抢购**: 实时倒计时、库存显示、防重复提交
|
- ⚡ **限时活动抢购**: 实时倒计时、库存显示、防重复提交
|
||||||
- 🛍️ **商品浏览**: 分类筛选、价格排序、关键词搜索
|
- 🛍️ **商品浏览**: 分类筛选、价格排序、关键词搜索
|
||||||
- 🛒 **购物车**: 商品管理、批量操作、结算功能
|
- 🛒 **购物车**: 商品管理、批量操作、结算功能
|
||||||
- 📦 **订单管理**: 订单列表、详情查看、状态跟踪
|
- 📦 **订单管理**: 订单列表、详情查看、状态跟踪
|
||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
|
|
||||||
- 📊 **数据仪表盘**: 实时统计、图表展示
|
- 📊 **数据仪表盘**: 实时统计、图表展示
|
||||||
- 📝 **内容管理**: 商品、秒杀、订单、用户管理
|
- 📝 **内容管理**: 商品、限时活动、订单、用户管理
|
||||||
- 📈 **数据分析**: 销售趋势、用户行为分析
|
- 📈 **数据分析**: 销售趋势、用户行为分析
|
||||||
|
|
||||||
### 高级特性
|
### 高级特性
|
||||||
|
|
||||||
- 🔔 **实时通知**: WebSocket消息推送、消息中心
|
- 🔔 **实时通知**: WebSocket消息推送、消息中心
|
||||||
- 🔍 **智能搜索**: 搜索历史、热门推荐、实时建议
|
- 🔍 **智能搜索**: 搜索历史、热门推荐、实时建议
|
||||||
- 📸 **图片上传**: 拖拽上传、预览下载、进度显示
|
- 📸 **图片上传**: 拖拽上传、预览下载、进度显示
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
flash-sale-frontend/
|
community-fresh-group-buy-frontend/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── api/ # API接口封装
|
│ ├── api/ # API接口封装
|
||||||
│ ├── assets/ # 静态资源
|
│ ├── assets/ # 静态资源
|
||||||
@@ -65,10 +68,12 @@ flash-sale-frontend/
|
|||||||
## 🔧 安装使用
|
## 🔧 安装使用
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- Node.js >= 16.0
|
- Node.js >= 16.0
|
||||||
- npm >= 8.0 或 yarn >= 1.22
|
- npm >= 8.0 或 yarn >= 1.22
|
||||||
|
|
||||||
### 安装依赖
|
### 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
# 或
|
# 或
|
||||||
@@ -76,14 +81,17 @@ yarn install
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 开发环境
|
### 开发环境
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# 或
|
# 或
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 http://localhost:3000
|
访问 http://localhost:3000
|
||||||
|
|
||||||
### 生产构建
|
### 生产构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
# 或
|
# 或
|
||||||
@@ -91,6 +99,7 @@ yarn build
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 预览构建结果
|
### 预览构建结果
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run preview
|
||||||
# 或
|
# 或
|
||||||
@@ -105,6 +114,7 @@ yarn preview
|
|||||||
## 📝 配置说明
|
## 📝 配置说明
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
|
|
||||||
修改 `.env.development` 或 `.env.production` 文件:
|
修改 `.env.development` 或 `.env.production` 文件:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
@@ -119,6 +129,7 @@ VITE_UPLOAD_URL=http://localhost:8080/upload
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 代理配置
|
### 代理配置
|
||||||
|
|
||||||
在 `vite.config.ts` 中配置开发环境代理:
|
在 `vite.config.ts` 中配置开发环境代理:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -135,6 +146,7 @@ server: {
|
|||||||
## 🚢 部署
|
## 🚢 部署
|
||||||
|
|
||||||
### Nginx配置示例
|
### Nginx配置示例
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -154,6 +166,7 @@ server {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Docker部署
|
### Docker部署
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY dist /usr/share/nginx/html
|
COPY dist /usr/share/nginx/html
|
||||||
@@ -181,9 +194,9 @@ MIT License
|
|||||||
|
|
||||||
## 📧 联系我们
|
## 📧 联系我们
|
||||||
|
|
||||||
- 邮箱: contact@flashsale.com
|
- 邮箱: contact@community-fresh-groupbuy.example
|
||||||
- 官网: https://flashsale.com
|
- 官网: https://community-fresh-groupbuy.example
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Made with ❤️ by Flash Sale Team
|
Made with ❤️ by Group Buy Team
|
||||||
15
community-fresh-group-buy-frontend/index.html
Normal file
15
community-fresh-group-buy-frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link href="/favicon.ico" rel="icon">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<title>社区生鲜团购系统</title>
|
||||||
|
<meta content="社区生鲜团购系统,提供商品浏览、拼团下单、订单管理和后台运营能力" name="description">
|
||||||
|
<meta content="拼团,团购,社区生鲜,community fresh group buying" name="keywords">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/src/main.ts" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "flash-sale-frontend",
|
"name": "community-fresh-group-buy-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flash-sale-frontend",
|
"name": "community-fresh-group-buy-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "flash-sale-frontend",
|
"name": "community-fresh-group-buy-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
6
community-fresh-group-buy-frontend/postcss.config.js
Normal file
6
community-fresh-group-buy-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-config-provider :locale="zhCn">
|
<el-config-provider :locale="zhCn">
|
||||||
<router-view />
|
<router-view/>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { onMounted } from 'vue'
|
import {onMounted} from 'vue'
|
||||||
import { ElConfigProvider } from 'element-plus'
|
import {ElConfigProvider} from 'element-plus'
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
34
community-fresh-group-buy-frontend/src/api/flashsale.ts
Normal file
34
community-fresh-group-buy-frontend/src/api/flashsale.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {FlashSale, FlashSaleParams} from '@/types/flashsale'
|
||||||
|
|
||||||
|
export const flashSaleApi = {
|
||||||
|
// 获取限时活动列表
|
||||||
|
getList(params?: FlashSaleParams) {
|
||||||
|
return request.get<any, { list: FlashSale[], total: number }>('/api/flashsales', {params})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取限时活动详情
|
||||||
|
getDetail(id: number) {
|
||||||
|
return request.get<any, FlashSale>(`/api/flashsales/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 参与限时
|
||||||
|
participate(flashSaleId: number, quantity: number = 1) {
|
||||||
|
return request.post('/api/flashsales/participate', {
|
||||||
|
flashSaleId,
|
||||||
|
quantity
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取正在进行的限时活动
|
||||||
|
getActive() {
|
||||||
|
return request.get<any, FlashSale[]>('/api/flashsales/active')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取即将开始的限时活动
|
||||||
|
getUpcoming() {
|
||||||
|
return request.get<any, FlashSale[]>('/api/flashsales/upcoming')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default flashSaleApi
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse} from '@/types/api'
|
||||||
|
|
||||||
|
export interface AddressItem {
|
||||||
|
id: number
|
||||||
|
userId?: number
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
address: string
|
||||||
|
isDefault: boolean
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveAddressParams {
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
address: string
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addressApi = {
|
||||||
|
getList(): Promise<ApiResponse<AddressItem[]>> {
|
||||||
|
return request.get('/api/address')
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefault(): Promise<ApiResponse<AddressItem>> {
|
||||||
|
return request.get('/api/address/default')
|
||||||
|
},
|
||||||
|
|
||||||
|
create(data: SaveAddressParams): Promise<ApiResponse<AddressItem>> {
|
||||||
|
return request.post('/api/address', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
update(id: number, data: SaveAddressParams): Promise<ApiResponse<AddressItem>> {
|
||||||
|
return request.put(`/api/address/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefault(id: number): Promise<ApiResponse<AddressItem>> {
|
||||||
|
return request.post(`/api/address/${id}/default`)
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/address/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
220
community-fresh-group-buy-frontend/src/api/modules/admin.ts
Normal file
220
community-fresh-group-buy-frontend/src/api/modules/admin.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse} from '@/types/api'
|
||||||
|
import type {
|
||||||
|
AdminDashboardStats,
|
||||||
|
AdminFavoriteRow,
|
||||||
|
AdminFavoriteStats,
|
||||||
|
AdminFlashSaleStats,
|
||||||
|
AdminHotProductRow,
|
||||||
|
AdminOrderRow,
|
||||||
|
AdminOrderStats,
|
||||||
|
AdminProductRow,
|
||||||
|
AdminProductStats,
|
||||||
|
AdminRecentOrderRow,
|
||||||
|
AdminReviewRow,
|
||||||
|
AdminReviewStats,
|
||||||
|
AdminUserRow,
|
||||||
|
AdminUserStats,
|
||||||
|
MonitorSystemStatus,
|
||||||
|
RedisNodeStatus,
|
||||||
|
} from '@/types/admin'
|
||||||
|
import {
|
||||||
|
normalizeAdminHotProduct,
|
||||||
|
normalizeAdminOrder,
|
||||||
|
normalizeAdminProduct,
|
||||||
|
normalizeAdminRecentOrder,
|
||||||
|
normalizeAdminUser,
|
||||||
|
} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getDashboardStats(): Promise<ApiResponse<AdminDashboardStats>> {
|
||||||
|
return request.get('/api/admin/dashboard/stats')
|
||||||
|
},
|
||||||
|
getUserStats(): Promise<ApiResponse<AdminUserStats>> {
|
||||||
|
return request.get('/api/admin/users/stats')
|
||||||
|
},
|
||||||
|
getOrderStats(): Promise<ApiResponse<AdminOrderStats>> {
|
||||||
|
return request.get('/api/admin/orders/stats')
|
||||||
|
},
|
||||||
|
getProductStats(): Promise<ApiResponse<AdminProductStats>> {
|
||||||
|
return request.get('/api/admin/products/stats')
|
||||||
|
},
|
||||||
|
getFlashSaleStats(): Promise<ApiResponse<AdminFlashSaleStats>> {
|
||||||
|
return request.get('/api/admin/flashsales/stats')
|
||||||
|
},
|
||||||
|
getRecentOrders(limit = 10): Promise<ApiResponse<AdminRecentOrderRow[]>> {
|
||||||
|
return request.get<ApiResponse<any[]>>('/api/admin/orders/recent', {limit}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminRecentOrder(item)) : [],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
getHotProducts(limit = 5): Promise<ApiResponse<AdminHotProductRow[]>> {
|
||||||
|
return request.get<ApiResponse<any[]>>('/api/admin/products/hot', {limit}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminHotProduct(item)) : [],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
getUsers(params: { page: number; size: number; keyword?: string; status?: number | '' }): Promise<ApiResponse<{
|
||||||
|
users: AdminUserRow[];
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
size: number
|
||||||
|
}>> {
|
||||||
|
const query = {
|
||||||
|
page: params.page,
|
||||||
|
size: params.size,
|
||||||
|
keyword: params.keyword,
|
||||||
|
status: params.status === '' ? undefined : params.status
|
||||||
|
}
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/admin/users', query).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
users: Array.isArray(res.data.users) ? res.data.users.map((item) => normalizeAdminUser(item)) : [],
|
||||||
|
total: Number(res.data.total || 0),
|
||||||
|
totalPages: Number(res.data.totalPages || 0),
|
||||||
|
currentPage: Number(res.data.currentPage || params.page),
|
||||||
|
size: Number(res.data.size || params.size),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
deleteUser(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/admin/users/${id}`)
|
||||||
|
},
|
||||||
|
getOrders(params: { page: number; size: number; keyword?: string; status?: string | '' }): Promise<ApiResponse<{
|
||||||
|
orders: AdminOrderRow[];
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
size: number
|
||||||
|
}>> {
|
||||||
|
const query = {
|
||||||
|
page: params.page,
|
||||||
|
size: params.size,
|
||||||
|
keyword: params.keyword,
|
||||||
|
status: params.status === '' ? undefined : params.status
|
||||||
|
}
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/admin/orders', query).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
orders: Array.isArray(res.data.orders) ? res.data.orders.map((item) => normalizeAdminOrder(item)) : [],
|
||||||
|
total: Number(res.data.total || 0),
|
||||||
|
totalPages: Number(res.data.totalPages || 0),
|
||||||
|
currentPage: Number(res.data.currentPage || params.page),
|
||||||
|
size: Number(res.data.size || params.size),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
getProducts(params: {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
keyword?: string;
|
||||||
|
category?: string;
|
||||||
|
status?: number | ''
|
||||||
|
}): Promise<ApiResponse<{
|
||||||
|
products: AdminProductRow[];
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
size: number
|
||||||
|
}>> {
|
||||||
|
const query = {
|
||||||
|
page: params.page,
|
||||||
|
size: params.size,
|
||||||
|
keyword: params.keyword,
|
||||||
|
category: params.category,
|
||||||
|
status: params.status === '' ? undefined : params.status
|
||||||
|
}
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/admin/products', query).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
products: Array.isArray(res.data.products) ? res.data.products.map((item) => normalizeAdminProduct(item)) : [],
|
||||||
|
total: Number(res.data.total || 0),
|
||||||
|
totalPages: Number(res.data.totalPages || 0),
|
||||||
|
currentPage: Number(res.data.currentPage || params.page),
|
||||||
|
size: Number(res.data.size || params.size),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
getSystemStatus(): Promise<ApiResponse<MonitorSystemStatus>> {
|
||||||
|
return request.get('/api/admin/monitor/system')
|
||||||
|
},
|
||||||
|
getRedisStatus(): Promise<ApiResponse<RedisNodeStatus[]>> {
|
||||||
|
return request.get('/api/admin/monitor/redis')
|
||||||
|
},
|
||||||
|
getProduct(id: number): Promise<ApiResponse<AdminProductRow>> {
|
||||||
|
return request.get<ApiResponse<any>>(`/api/admin/products/${id}`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeAdminProduct(res.data)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
createProduct(data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
|
||||||
|
return request.post<ApiResponse<any>>('/api/admin/products', data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeAdminProduct(res.data)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
updateProduct(id: number, data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
|
||||||
|
return request.put<ApiResponse<any>>(`/api/admin/products/${id}`, data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: res.data ? normalizeAdminProduct(res.data) : undefined,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
deleteProduct(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/admin/products/${id}`)
|
||||||
|
},
|
||||||
|
getReviewStats(): Promise<ApiResponse<AdminReviewStats>> {
|
||||||
|
return request.get('/api/admin/reviews/stats')
|
||||||
|
},
|
||||||
|
getFavoriteStats(): Promise<ApiResponse<AdminFavoriteStats>> {
|
||||||
|
return request.get('/api/admin/favorites/stats')
|
||||||
|
},
|
||||||
|
getReviews(params: { page: number; size: number; keyword?: string }): Promise<ApiResponse<{
|
||||||
|
reviews: AdminReviewRow[];
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
size: number
|
||||||
|
}>> {
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/admin/reviews', params).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
reviews: Array.isArray(res.data.reviews) ? (res.data.reviews as AdminReviewRow[]) : [],
|
||||||
|
total: Number(res.data.total || 0),
|
||||||
|
totalPages: Number(res.data.totalPages || 0),
|
||||||
|
currentPage: Number(res.data.currentPage || params.page),
|
||||||
|
size: Number(res.data.size || params.size),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
updateReview(id: number, data: { status?: number; adminReply?: string }): Promise<ApiResponse> {
|
||||||
|
return request.put(`/api/admin/reviews/${id}`, data)
|
||||||
|
},
|
||||||
|
deleteReview(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/admin/reviews/${id}`)
|
||||||
|
},
|
||||||
|
getFavorites(params: { page: number; size: number; keyword?: string }): Promise<ApiResponse<{
|
||||||
|
favorites: AdminFavoriteRow[];
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
size: number
|
||||||
|
}>> {
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/admin/favorites', params).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
favorites: Array.isArray(res.data.favorites) ? (res.data.favorites as AdminFavoriteRow[]) : [],
|
||||||
|
total: Number(res.data.total || 0),
|
||||||
|
totalPages: Number(res.data.totalPages || 0),
|
||||||
|
currentPage: Number(res.data.currentPage || params.page),
|
||||||
|
size: Number(res.data.size || params.size),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
deleteFavorite(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/admin/favorites/${id}`)
|
||||||
|
},
|
||||||
|
migrateLegacyOrderItems(): Promise<ApiResponse<{ totalOrders: number; migrated: number; skipped: number }>> {
|
||||||
|
return request.post('/api/admin/orders/migrate-items')
|
||||||
|
},
|
||||||
|
}
|
||||||
55
community-fresh-group-buy-frontend/src/api/modules/cart.ts
Normal file
55
community-fresh-group-buy-frontend/src/api/modules/cart.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse, CartItem} from '@/types/api'
|
||||||
|
import {normalizeCartItems, normalizeOrder} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
export const cartApi = {
|
||||||
|
// 获取购物车
|
||||||
|
getCart(): Promise<ApiResponse<CartItem[]>> {
|
||||||
|
return request.get<ApiResponse<any>>('/api/cart').then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeCartItems(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加到购物车
|
||||||
|
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/update', {productId: Number(itemId), quantity})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除商品
|
||||||
|
removeItem(itemId: string): Promise<ApiResponse> {
|
||||||
|
return request.delete('/api/cart/remove', {productId: Number(itemId)})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
batchRemove(ids: string[]): Promise<ApiResponse> {
|
||||||
|
return request.delete('/api/cart/batch-remove', {productIds: ids.map(Number)})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空购物车
|
||||||
|
clearCart(): Promise<ApiResponse> {
|
||||||
|
return request.delete('/api/cart/clear')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取购物车数量
|
||||||
|
getCount(): Promise<ApiResponse<{ count: number }>> {
|
||||||
|
return request.get('/api/cart/count')
|
||||||
|
},
|
||||||
|
|
||||||
|
checkout(ids?: string[]): Promise<ApiResponse<any>> {
|
||||||
|
return request.post<ApiResponse<any>>('/api/cart/checkout', {
|
||||||
|
productIds: ids?.map(Number),
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeOrder(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse} from '@/types/api'
|
||||||
|
|
||||||
|
export interface FavoriteItem {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productImageUrl: string
|
||||||
|
productPrice: number
|
||||||
|
productCategory: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const favoriteApi = {
|
||||||
|
getList(): Promise<ApiResponse<FavoriteItem[]>> {
|
||||||
|
return request.get('/api/favorite')
|
||||||
|
},
|
||||||
|
|
||||||
|
getCount(): Promise<ApiResponse<{ count: number }>> {
|
||||||
|
return request.get('/api/favorite/count')
|
||||||
|
},
|
||||||
|
|
||||||
|
check(productId: number): Promise<ApiResponse<{ favorited: boolean }>> {
|
||||||
|
return request.get('/api/favorite/check', {productId})
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(productId: number): Promise<ApiResponse<{ favorited: boolean }>> {
|
||||||
|
return request.post('/api/favorite/toggle', {productId})
|
||||||
|
},
|
||||||
|
}
|
||||||
164
community-fresh-group-buy-frontend/src/api/modules/flashsale.ts
Normal file
164
community-fresh-group-buy-frontend/src/api/modules/flashsale.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse, FlashSale, PageParams, PageResponse} from '@/types/api'
|
||||||
|
import {mapOrderStatus, normalizeFlashSale, normalizePage} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
const flashSaleStatusToCode = (status?: string) => {
|
||||||
|
if (status === 'UPCOMING') return 1
|
||||||
|
if (status === 'ACTIVE') return 2
|
||||||
|
if (status === 'ENDED') return 3
|
||||||
|
if (status === 'PAUSED') return 4
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const flashSaleSortField = (sort?: string) => {
|
||||||
|
if (sort === 'flashPrice') return 'flashPrice'
|
||||||
|
if (sort === 'endTime') return 'endTime'
|
||||||
|
return 'startTime'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const flashsaleApi = {
|
||||||
|
// 获取限时活动统计信息(即将开始/正在进行/我的参与/抢购成功)
|
||||||
|
getStatistics(): Promise<ApiResponse<{ upcoming: number; active: number; participated: number; success: number }>> {
|
||||||
|
return request.get('/api/flashsale/statistics')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取限时活动列表
|
||||||
|
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<FlashSale>>> {
|
||||||
|
return request.post<ApiResponse<Record<string, any>>>('/api/flashsale/list', {
|
||||||
|
status: flashSaleStatusToCode(params?.status),
|
||||||
|
page: params?.page ?? 0,
|
||||||
|
size: params?.size ?? 10,
|
||||||
|
sortBy: flashSaleSortField(params?.sort),
|
||||||
|
sortDirection: params?.order || 'asc',
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizePage(res.data, normalizeFlashSale),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取正在进行的限时活动
|
||||||
|
getActive(limit?: number): Promise<ApiResponse<FlashSale[]>> {
|
||||||
|
return request.get<ApiResponse<any[]>>('/api/flashsale/active').then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: (Array.isArray(res.data) ? res.data : [])
|
||||||
|
.map((item) => normalizeFlashSale(item))
|
||||||
|
.slice(0, limit ?? Number.MAX_SAFE_INTEGER),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取限时活动详情
|
||||||
|
getDetail(id: number): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.get<ApiResponse<any>>(`/api/flashsale/${id}`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 参与限时
|
||||||
|
participate(data: {
|
||||||
|
flashSaleId: number;
|
||||||
|
quantity: number;
|
||||||
|
timestamp?: number;
|
||||||
|
}): Promise<ApiResponse<{ orderId: number }>> {
|
||||||
|
return request.post<ApiResponse<any>>('/api/flashsale/participate', data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
orderId: Number(res.data?.orderId || res.data?.id || 0),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户参与记录
|
||||||
|
getUserRecords(): Promise<ApiResponse<any[]>> {
|
||||||
|
return request.post<ApiResponse<Record<string, any>>>('/api/order/my-orders', {
|
||||||
|
orderType: 2,
|
||||||
|
page: 0,
|
||||||
|
size: 100,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortDirection: 'desc',
|
||||||
|
}).then((res) => {
|
||||||
|
const content = Array.isArray(res.data?.content) ? res.data.content : []
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: content.map((item: Record<string, any>) => ({
|
||||||
|
id: item.id,
|
||||||
|
success: mapOrderStatus(item.status) !== 'CANCELLED',
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查用户是否可以参与
|
||||||
|
checkEligibility(flashSaleId: number): Promise<ApiResponse<{
|
||||||
|
eligible: boolean;
|
||||||
|
reason?: string;
|
||||||
|
remainingQuota?: number;
|
||||||
|
}>> {
|
||||||
|
return this.getDetail(flashSaleId).then((res) => {
|
||||||
|
const eligible = res.data.status === 'ACTIVE' && res.data.remainingStock > 0
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
success: true,
|
||||||
|
message: '检查成功',
|
||||||
|
data: {
|
||||||
|
eligible,
|
||||||
|
reason: eligible ? '' : '活动未开始、已结束或库存不足',
|
||||||
|
remainingQuota: res.data.limitPerUser,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
create(data: {
|
||||||
|
productId: number
|
||||||
|
flashPrice: number
|
||||||
|
flashStock: number
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.post<ApiResponse<any>>('/api/flashsale/create', data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.put<ApiResponse<any>>(`/api/flashsale/${id}`, data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/flashsale/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
publish(id: number): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/publish`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
pause(id: number): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/pause`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
resume(id: number): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/resume`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
end(id: number): Promise<ApiResponse<FlashSale>> {
|
||||||
|
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/end`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeFlashSale(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
GroupBuying,
|
||||||
|
GroupBuyingGroup,
|
||||||
|
GroupBuyingStatistics,
|
||||||
|
PageParams,
|
||||||
|
PageResponse
|
||||||
|
} from '@/types/api'
|
||||||
|
import {normalizeGroupBuying, normalizeGroupBuyingGroup, normalizePage} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
const groupBuyingStatusToCode = (status?: string) => {
|
||||||
|
if (status === 'DRAFT') return 0
|
||||||
|
if (status === 'UPCOMING') return 1
|
||||||
|
if (status === 'ACTIVE') return 2
|
||||||
|
if (status === 'ENDED') return 3
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupbuyingApi = {
|
||||||
|
getStatistics(): Promise<ApiResponse<GroupBuyingStatistics>> {
|
||||||
|
return request.get('/api/groupbuying/statistics')
|
||||||
|
},
|
||||||
|
|
||||||
|
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<GroupBuying>>> {
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/list', {
|
||||||
|
status: groupBuyingStatusToCode(params?.status),
|
||||||
|
page: params?.page ?? 0,
|
||||||
|
size: params?.size ?? 10,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizePage(res.data, normalizeGroupBuying),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
getDetail(id: number): Promise<ApiResponse<GroupBuying>> {
|
||||||
|
return request.get<ApiResponse<any>>(`/api/groupbuying/${id}`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeGroupBuying(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
getGroups(id: number, params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>(`/api/groupbuying/${id}/groups`, {
|
||||||
|
page: params?.page ?? 0,
|
||||||
|
size: params?.size ?? 10,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizePage(res.data, normalizeGroupBuyingGroup),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
joinGroup(data: { groupBuyingId: number; groupId?: number }): Promise<ApiResponse<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
groupId: number
|
||||||
|
groupNo: string
|
||||||
|
orderId: number
|
||||||
|
}>> {
|
||||||
|
return request.post('/api/groupbuying/join', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
getGroupDetail(groupId: number): Promise<ApiResponse<GroupBuyingGroup>> {
|
||||||
|
return request.get<ApiResponse<any>>(`/api/groupbuying/group/${groupId}`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeGroupBuyingGroup(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelMembership(groupId: number): Promise<ApiResponse> {
|
||||||
|
return request.post(`/api/groupbuying/group/${groupId}/cancel`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyGroups(params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/my-groups', {
|
||||||
|
page: params?.page ?? 0,
|
||||||
|
size: params?.size ?? 10,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizePage(res.data, normalizeGroupBuyingGroup),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
create(data: {
|
||||||
|
productId: number
|
||||||
|
groupPrice: number
|
||||||
|
requiredMembers: number
|
||||||
|
durationMinutes: number
|
||||||
|
totalStock: number
|
||||||
|
maxPerUser: number
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}): Promise<ApiResponse<GroupBuying>> {
|
||||||
|
return request.post<ApiResponse<any>>('/api/groupbuying/admin/create', data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeGroupBuying(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<GroupBuying>> {
|
||||||
|
return request.put<ApiResponse<any>>(`/api/groupbuying/admin/${id}`, data).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeGroupBuying(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id: number): Promise<ApiResponse> {
|
||||||
|
return request.delete(`/api/groupbuying/admin/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
preloadAll(): Promise<ApiResponse> {
|
||||||
|
return request.post('/api/groupbuying/admin/preload-all')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
type: 'flashsale' | 'order' | 'system'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
link?: string
|
||||||
|
read: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiRes<T = any> {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationApi = {
|
||||||
|
/** 获取通知列表 */
|
||||||
|
getList(type?: string): Promise<ApiRes<NotificationItem[]>> {
|
||||||
|
return request.get('/api/notification/list', type ? {type} : undefined)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取未读数量 */
|
||||||
|
getUnreadCount(): Promise<ApiRes<number>> {
|
||||||
|
return request.get('/api/notification/unread-count')
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 标记单条已读 */
|
||||||
|
markAsRead(id: number): Promise<ApiRes> {
|
||||||
|
return request.put(`/api/notification/${id}/read`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 全部标记已读 */
|
||||||
|
markAllAsRead(): Promise<ApiRes> {
|
||||||
|
return request.put('/api/notification/read-all')
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清空所有通知 */
|
||||||
|
clearAll(): Promise<ApiRes> {
|
||||||
|
return request.delete('/api/notification/clear')
|
||||||
|
}
|
||||||
|
}
|
||||||
145
community-fresh-group-buy-frontend/src/api/modules/order.ts
Normal file
145
community-fresh-group-buy-frontend/src/api/modules/order.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse, Order, PageParams, PageResponse} from '@/types/api'
|
||||||
|
import {normalizeOrder} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
const orderStatusToCode = (status?: string) => {
|
||||||
|
if (status === 'PENDING') return 1
|
||||||
|
if (status === 'PAID') return 2
|
||||||
|
if (status === 'SHIPPED') return 3
|
||||||
|
if (status === 'COMPLETED') return 4
|
||||||
|
if (status === 'CANCELLED') return 5
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregateOrders = (rawOrders: Array<Record<string, any>>): Order[] => {
|
||||||
|
const groups = new Map<string, Array<Record<string, any>>>()
|
||||||
|
|
||||||
|
rawOrders.forEach((item) => {
|
||||||
|
const key = item.groupNo || item.orderNo || String(item.id)
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, [])
|
||||||
|
}
|
||||||
|
groups.get(key)!.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(groups.values()).map((group) => {
|
||||||
|
const [anchor] = group
|
||||||
|
const normalizedAnchor = normalizeOrder(anchor)
|
||||||
|
|
||||||
|
const items = group.flatMap((item) => normalizeOrder(item).items)
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + item.subtotal, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalizedAnchor,
|
||||||
|
orderNo: anchor.groupNo || normalizedAnchor.orderNo,
|
||||||
|
totalAmount,
|
||||||
|
paymentAmount: totalAmount,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderApi = {
|
||||||
|
create(data: {
|
||||||
|
items: Array<{ productId: number; quantity: number }>
|
||||||
|
addressId?: number
|
||||||
|
remark?: string
|
||||||
|
}): Promise<ApiResponse<Order>> {
|
||||||
|
const [firstItem] = data.items
|
||||||
|
return request.post<ApiResponse<any>>('/api/order/create', {
|
||||||
|
productId: firstItem.productId,
|
||||||
|
quantity: firstItem.quantity,
|
||||||
|
remark: data.remark,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeOrder(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<Order>>> {
|
||||||
|
return request.post<ApiResponse<Record<string, any>>>('/api/order/my-orders', {
|
||||||
|
status: orderStatusToCode(params?.status),
|
||||||
|
page: params?.page ?? 0,
|
||||||
|
size: params?.size ?? 10,
|
||||||
|
sortBy: params?.sort || 'createdAt',
|
||||||
|
sortDirection: params?.order || 'desc',
|
||||||
|
}).then((res) => {
|
||||||
|
const rawContent = Array.isArray(res.data.content) ? res.data.content : []
|
||||||
|
const content = aggregateOrders(rawContent)
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
totalElements: Number(res.data.totalElements || content.length),
|
||||||
|
totalPages: Number(res.data.totalPages || 1),
|
||||||
|
size: res.data.size || content.length,
|
||||||
|
number: res.data.currentPage || 0,
|
||||||
|
first: true,
|
||||||
|
last: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getDetail(id: number): Promise<ApiResponse<Order>> {
|
||||||
|
return request.get<ApiResponse<any>>(`/api/order/${id}`).then(async (res) => {
|
||||||
|
if (res.data.groupNo) {
|
||||||
|
const groupRes = await request.get<ApiResponse<any[]>>(`/api/order/group/${res.data.groupNo}`)
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: aggregateOrders(groupRes.data)[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: normalizeOrder(res.data),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
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})
|
||||||
|
},
|
||||||
|
|
||||||
|
ship(id: number): Promise<ApiResponse> {
|
||||||
|
return request.post(`/api/order/${id}/ship`)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus(id: number, status: number, remark?: string): Promise<ApiResponse> {
|
||||||
|
return request.put('/api/order/status', {orderId: id, status, remark})
|
||||||
|
},
|
||||||
|
|
||||||
|
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<ApiResponse<any>>('/api/order/statistics').then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
total: Number(res.data.totalOrders || 0),
|
||||||
|
pending: Number(res.data.pendingPaymentOrders || 0),
|
||||||
|
paid: Number(res.data.paidOrders || 0),
|
||||||
|
shipped: Number(res.data.shippedOrders || 0),
|
||||||
|
completed: Number(res.data.completedOrders || 0),
|
||||||
|
cancelled: Number(res.data.cancelledOrders || 0),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse, Product, PageParams, PageResponse} from '@/types/api'
|
||||||
|
import {normalizePage, normalizeProduct} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
export const productApi = {
|
||||||
|
// 获取商品列表
|
||||||
|
getList(params?: PageParams & {
|
||||||
|
keyword?: string;
|
||||||
|
category?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
}): Promise<ApiResponse<PageResponse<Product>>> {
|
||||||
|
return request.get<ApiResponse<Record<string, any>>>('/api/product/list', {
|
||||||
|
page: params?.page ?? 0,
|
||||||
|
size: params?.size ?? 10,
|
||||||
|
keyword: params?.keyword,
|
||||||
|
category: params?.category,
|
||||||
|
minPrice: params?.minPrice,
|
||||||
|
maxPrice: params?.maxPrice,
|
||||||
|
sortBy: params?.sort || 'id',
|
||||||
|
sortDirection: params?.order || 'desc',
|
||||||
|
status: 1,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizePage(res.data, normalizeProduct),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取热门商品
|
||||||
|
getHot(limit = 8): Promise<ApiResponse<Product[]>> {
|
||||||
|
return request.get<ApiResponse<any[]>>('/api/product/hot', {limit}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: Array.isArray(res.data) ? res.data.map((item) => normalizeProduct(item)) : [],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取商品详情
|
||||||
|
getDetail(id: number): Promise<ApiResponse<Product>> {
|
||||||
|
return request.get<ApiResponse<any>>(`/api/product/${id}`).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeProduct(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 搜索商品
|
||||||
|
search(keyword: string): Promise<ApiResponse<Product[]>> {
|
||||||
|
return this.getList({keyword, page: 0, size: 50}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: res.data.content,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取商品分类
|
||||||
|
getCategories(): Promise<ApiResponse<string[]>> {
|
||||||
|
return request.get('/api/product/categories')
|
||||||
|
},
|
||||||
|
}
|
||||||
61
community-fresh-group-buy-frontend/src/api/modules/return.ts
Normal file
61
community-fresh-group-buy-frontend/src/api/modules/return.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse, OrderReturn} from '@/types/api'
|
||||||
|
import {normalizeOrderReturn} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
export const returnApi = {
|
||||||
|
create(data: {
|
||||||
|
orderId: number;
|
||||||
|
reason: string;
|
||||||
|
description?: string;
|
||||||
|
images?: string
|
||||||
|
}): Promise<ApiResponse<OrderReturn>> {
|
||||||
|
return request.post('/api/return/create', data).then(normalizeResponse)
|
||||||
|
},
|
||||||
|
|
||||||
|
getByOrderId(orderId: number): Promise<ApiResponse<OrderReturn | null>> {
|
||||||
|
return request.get(`/api/return/order/${orderId}`)
|
||||||
|
.then((res: any) => ({
|
||||||
|
...res,
|
||||||
|
data: res.data ? normalizeOrderReturn(res.data) : null,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyReturns(params?: { status?: number; page?: number; size?: number }): Promise<ApiResponse<any>> {
|
||||||
|
return request.get('/api/return/my', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
ship(id: number, returnTracking: string): Promise<ApiResponse<OrderReturn>> {
|
||||||
|
return request.post(`/api/return/${id}/ship`, {returnTracking}).then(normalizeResponse)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel(id: number): Promise<ApiResponse<OrderReturn>> {
|
||||||
|
return request.post(`/api/return/${id}/cancel`).then(normalizeResponse)
|
||||||
|
},
|
||||||
|
|
||||||
|
adminReview(id: number, data: {
|
||||||
|
status: number;
|
||||||
|
rejectReason?: string;
|
||||||
|
adminRemark?: string
|
||||||
|
}): Promise<ApiResponse<OrderReturn>> {
|
||||||
|
return request.post(`/api/return/${id}/review`, data).then(normalizeResponse)
|
||||||
|
},
|
||||||
|
|
||||||
|
adminComplete(id: number, remark?: string): Promise<ApiResponse<OrderReturn>> {
|
||||||
|
return request.post(`/api/return/${id}/complete`, {remark}).then(normalizeResponse)
|
||||||
|
},
|
||||||
|
|
||||||
|
getAll(params?: { status?: number; page?: number; size?: number }): Promise<ApiResponse<any>> {
|
||||||
|
return request.get('/api/return/all', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatistics(): Promise<ApiResponse<any>> {
|
||||||
|
return request.get('/api/return/statistics')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponse(res: any): any {
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
data: res.data ? normalizeOrderReturn(res.data) : res.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
55
community-fresh-group-buy-frontend/src/api/modules/review.ts
Normal file
55
community-fresh-group-buy-frontend/src/api/modules/review.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse} from '@/types/api'
|
||||||
|
|
||||||
|
export interface ReviewItem {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
userId: number
|
||||||
|
orderId: number
|
||||||
|
username: string
|
||||||
|
productName?: string
|
||||||
|
productImage?: string
|
||||||
|
rating: number
|
||||||
|
content: string
|
||||||
|
adminReply?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSummary {
|
||||||
|
averageRating: number
|
||||||
|
totalReviews: number
|
||||||
|
reviews: ReviewItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewCheckResult {
|
||||||
|
reviewed: boolean
|
||||||
|
review?: ReviewItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reviewApi = {
|
||||||
|
getProductReviews(productId: number): Promise<ApiResponse<ReviewSummary>> {
|
||||||
|
return request.get(`/api/review/product/${productId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
create(data: {
|
||||||
|
orderId: number;
|
||||||
|
productId: number;
|
||||||
|
rating: number;
|
||||||
|
content: string
|
||||||
|
}): Promise<ApiResponse<ReviewItem>> {
|
||||||
|
return request.post('/api/review', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
checkReview(orderId: number, productId: number): Promise<ApiResponse<ReviewCheckResult>> {
|
||||||
|
return request.get('/api/review/check', {orderId, productId})
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyReviews(): Promise<ApiResponse<ReviewItem[]>> {
|
||||||
|
return request.get('/api/review/my')
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrderReviews(orderId: number): Promise<ApiResponse<ReviewItem[]>> {
|
||||||
|
return request.get(`/api/review/order/${orderId}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
69
community-fresh-group-buy-frontend/src/api/modules/user.ts
Normal file
69
community-fresh-group-buy-frontend/src/api/modules/user.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {request} from '../request'
|
||||||
|
import type {ApiResponse, User, LoginParams, RegisterParams} from '@/types/api'
|
||||||
|
import {normalizeUser} from '@/utils/normalizers'
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
// 登录
|
||||||
|
login(params: LoginParams): Promise<ApiResponse<{ token: string; user: User }>> {
|
||||||
|
return request.post<ApiResponse<{ token: string; user: any }>>('/api/user/login', params).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
token: res.data.token,
|
||||||
|
user: normalizeUser(res.data.user),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
register(params: RegisterParams): Promise<ApiResponse<User>> {
|
||||||
|
return request.post<ApiResponse<any>>('/api/user/register', {
|
||||||
|
...params,
|
||||||
|
confirmPassword: params.password,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeUser(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
logout(): Promise<ApiResponse> {
|
||||||
|
return request.post('/api/user/logout')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
getInfo(): Promise<ApiResponse<User>> {
|
||||||
|
return request.get<ApiResponse<any>>('/api/user/current').then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeUser(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
updateInfo(data: Partial<User>): Promise<ApiResponse<User>> {
|
||||||
|
return request.put<ApiResponse<any>>('/api/user/update', {
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
avatar: data.avatar,
|
||||||
|
}).then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: normalizeUser(res.data),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
changePassword(data: { oldPassword: string; newPassword: string; confirmPassword?: string }): Promise<ApiResponse> {
|
||||||
|
return request.post('/api/user/change-password', {
|
||||||
|
...data,
|
||||||
|
confirmPassword: data.confirmPassword || data.newPassword,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getProfileStats(): Promise<ApiResponse<{
|
||||||
|
totalOrders: number;
|
||||||
|
totalAmount: number;
|
||||||
|
flashSaleSuccess: number;
|
||||||
|
favoriteCount: number
|
||||||
|
}>> {
|
||||||
|
return request.get('/api/user/profile-stats')
|
||||||
|
},
|
||||||
|
}
|
||||||
154
community-fresh-group-buy-frontend/src/api/request.ts
Normal file
154
community-fresh-group-buy-frontend/src/api/request.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosResponse,
|
||||||
|
InternalAxiosRequestConfig,
|
||||||
|
} 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 || '',
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: Number(import.meta.env.VITE_TIMEOUT) || 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
service.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
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 (typeof res?.success === 'boolean') {
|
||||||
|
if (!res.success) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
ElMessage.error(res.message || '登录状态已失效')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(res.message || '请求失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义状态码处理
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 提取后端返回的业务错误消息
|
||||||
|
const bizMessage = error.response?.data?.message
|
||||||
|
let displayMessage = ''
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 400:
|
||||||
|
// 业务错误(如"该团组已满员"),只显示后端返回的消息
|
||||||
|
displayMessage = bizMessage || '请求参数错误'
|
||||||
|
break
|
||||||
|
case 401:
|
||||||
|
displayMessage = '未授权,请登录'
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
displayMessage = '拒绝访问'
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
displayMessage = '请求地址不存在'
|
||||||
|
break
|
||||||
|
case 429:
|
||||||
|
displayMessage = '请求过于频繁,请稍后再试'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
displayMessage = '服务器内部错误'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
displayMessage = bizMessage || '请求失败'
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
displayMessage = '网络错误,请检查网络连接'
|
||||||
|
} else {
|
||||||
|
displayMessage = '请求配置错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(displayMessage)
|
||||||
|
|
||||||
|
// 用业务消息替换原始 axios error message,避免组件 catch 显示 "Request failed with status code 400"
|
||||||
|
const rejectError = new Error(displayMessage)
|
||||||
|
;(rejectError as any)._handled = true
|
||||||
|
return Promise.reject(rejectError)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 通用请求方法
|
||||||
|
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, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return service.delete(url, {
|
||||||
|
...config,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default service
|
||||||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 533 B |
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="countdown-timer">
|
<div class="countdown-timer">
|
||||||
<template v-if="timeLeft > 0">
|
<template v-if="timeLeft > 0">
|
||||||
<el-icon class="countdown-icon mr-1"><Clock /></el-icon>
|
<el-icon class="countdown-icon mr-1">
|
||||||
|
<Clock/>
|
||||||
|
</el-icon>
|
||||||
<span class="time-block">{{ hours.toString().padStart(2, '0') }}</span>
|
<span class="time-block">{{ hours.toString().padStart(2, '0') }}</span>
|
||||||
<span class="separator">:</span>
|
<span class="separator">:</span>
|
||||||
<span class="time-block">{{ minutes.toString().padStart(2, '0') }}</span>
|
<span class="time-block">{{ minutes.toString().padStart(2, '0') }}</span>
|
||||||
@@ -12,8 +14,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
endTime: number
|
endTime: number
|
||||||
@@ -34,7 +36,7 @@ const updateTime = () => {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const remaining = Math.max(0, Math.floor((props.endTime - now) / 1000))
|
const remaining = Math.max(0, Math.floor((props.endTime - now) / 1000))
|
||||||
timeLeft.value = remaining
|
timeLeft.value = remaining
|
||||||
|
|
||||||
if (remaining === 0 && timer) {
|
if (remaining === 0 && timer) {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
timer = null
|
timer = null
|
||||||
@@ -56,10 +58,10 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.countdown-timer {
|
.countdown-timer {
|
||||||
@apply flex items-center justify-center text-lg font-mono;
|
@apply flex items-center justify-center text-lg font-mono;
|
||||||
|
|
||||||
.countdown-icon {
|
.countdown-icon {
|
||||||
color: #5e5e58;
|
color: #5e5e58;
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,7 @@ onUnmounted(() => {
|
|||||||
color: #171715;
|
color: #171715;
|
||||||
border: 1px solid #171715;
|
border: 1px solid #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
@apply mx-1 font-bold;
|
@apply mx-1 font-bold;
|
||||||
color: #5e5e58;
|
color: #5e5e58;
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flash-sale-card card-shadow">
|
<div class="activity-card card-shadow">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="data.productImageUrl"
|
:alt="data.productName"
|
||||||
:alt="data.productName"
|
:src="data.productImageUrl"
|
||||||
wrapper-class="w-full h-48"
|
img-class="w-full h-48 object-cover"
|
||||||
img-class="w-full h-48 object-cover"
|
wrapper-class="w-full h-48"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="absolute top-2 left-2">
|
<div class="absolute top-2 left-2">
|
||||||
<el-tag :type="statusType" effect="dark" size="small">
|
<el-tag :type="statusType" effect="dark" size="small">
|
||||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,24 +33,27 @@
|
|||||||
<span>剩余: {{ data.remainingStock }}件</span>
|
<span>剩余: {{ data.remainingStock }}件</span>
|
||||||
<span>{{ stockPercent }}%</span>
|
<span>{{ stockPercent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<el-progress :percentage="stockPercent" :stroke-width="6" :show-text="false" :color="progressColor" />
|
<el-progress :color="progressColor" :percentage="stockPercent" :show-text="false" :stroke-width="6"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mb-3">
|
<div class="text-center mb-3">
|
||||||
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')" />
|
<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-if="data.status === 'UPCOMING'" class="text-sm text-gray-500">即将开始</span>
|
||||||
<span v-else class="text-sm text-gray-400">已结束</span>
|
<span v-else class="text-sm text-gray-400">已结束</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" class="w-full" :disabled="!canParticipate" :loading="loading" @click="handleParticipate">
|
<el-button :disabled="!canParticipate" :loading="loading" class="w-full" type="primary"
|
||||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
@click="handleParticipate">
|
||||||
|
<el-icon class="mr-1">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue'
|
import {ref, computed} from 'vue'
|
||||||
import type { FlashSale } from '@/types/api'
|
import type {FlashSale} from '@/types/api'
|
||||||
import CountDown from './CountDown.vue'
|
import CountDown from './CountDown.vue'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
@@ -58,22 +63,31 @@ const loading = ref(false)
|
|||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
switch (props.data.status) {
|
switch (props.data.status) {
|
||||||
case 'UPCOMING': return 'warning'
|
case 'UPCOMING':
|
||||||
case 'ACTIVE': return 'danger'
|
return 'warning'
|
||||||
case 'ENDED': return 'info'
|
case 'ACTIVE':
|
||||||
case 'PAUSED': return 'warning'
|
return 'danger'
|
||||||
default: return 'info'
|
case 'ENDED':
|
||||||
|
return 'info'
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.data.status) {
|
switch (props.data.status) {
|
||||||
case 'UPCOMING': return '即将开始'
|
case 'UPCOMING':
|
||||||
|
return '即将开始'
|
||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
return '进行中'
|
return '进行中'
|
||||||
case 'ENDED': return '已结束'
|
case 'ENDED':
|
||||||
case 'PAUSED': return '已暂停'
|
return '已结束'
|
||||||
default: return '未知'
|
case 'PAUSED':
|
||||||
|
return '已暂停'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,12 +107,14 @@ const handleParticipate = async () => {
|
|||||||
if (!canParticipate.value) return
|
if (!canParticipate.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
emit('participate', props.data.id)
|
emit('participate', props.data.id)
|
||||||
setTimeout(() => { loading.value = false }, 1000)
|
setTimeout(() => {
|
||||||
|
loading.value = false
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.flash-sale-card {
|
.activity-card {
|
||||||
@apply bg-white rounded-2xl overflow-hidden;
|
@apply bg-white rounded-2xl overflow-hidden;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
<div class="group-buying-card card-shadow" @click="$router.push(`/groupbuying/${data.id}`)">
|
<div class="group-buying-card card-shadow" @click="$router.push(`/groupbuying/${data.id}`)">
|
||||||
<div class="relative cursor-pointer">
|
<div class="relative cursor-pointer">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="data.productImageUrl"
|
:alt="data.productName"
|
||||||
:alt="data.productName"
|
:src="data.productImageUrl"
|
||||||
wrapper-class="w-full h-48"
|
img-class="w-full h-48 object-cover"
|
||||||
img-class="w-full h-48 object-cover"
|
wrapper-class="w-full h-48"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="absolute top-2 left-2">
|
<div class="absolute top-2 left-2">
|
||||||
<el-tag :type="statusType" effect="dark" size="small">
|
<el-tag :type="statusType" effect="dark" size="small">
|
||||||
<el-icon class="mr-1"><Connection /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,30 +29,34 @@
|
|||||||
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.productPrice }}</span>
|
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.productPrice }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||||
<el-icon class="mr-1"><User /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<User/>
|
||||||
|
</el-icon>
|
||||||
<span>{{ data.requiredMembers }}人团</span>
|
<span>{{ data.requiredMembers }}人团</span>
|
||||||
<span class="mx-2">|</span>
|
<span class="mx-2">|</span>
|
||||||
<span>剩余 {{ data.remainingStock }} 件</span>
|
<span>剩余 {{ data.remainingStock }} 件</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<el-progress :percentage="stockPercent" :stroke-width="6" :show-text="false" :color="progressColor" />
|
<el-progress :color="progressColor" :percentage="stockPercent" :show-text="false" :stroke-width="6"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-3">
|
<div class="flex items-center justify-between text-sm text-gray-500 mb-3">
|
||||||
<span v-if="data.activeGroupCount > 0">{{ data.activeGroupCount }} 个团进行中</span>
|
<span v-if="data.activeGroupCount > 0">{{ data.activeGroupCount }} 个团进行中</span>
|
||||||
<span v-else>暂无进行中的团</span>
|
<span v-else>暂无进行中的团</span>
|
||||||
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')" />
|
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')"/>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" class="w-full" :disabled="!canJoin" @click.stop="handleJoin">
|
<el-button :disabled="!canJoin" class="w-full" type="primary" @click.stop="handleJoin">
|
||||||
<el-icon class="mr-1"><Connection /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import {computed} from 'vue'
|
||||||
import type { GroupBuying } from '@/types/api'
|
import type {GroupBuying} from '@/types/api'
|
||||||
import CountDown from './CountDown.vue'
|
import CountDown from './CountDown.vue'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
@@ -59,20 +65,29 @@ const emit = defineEmits<{ join: [id: number]; refresh: [] }>()
|
|||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
switch (props.data.status) {
|
switch (props.data.status) {
|
||||||
case 'UPCOMING': return 'warning'
|
case 'UPCOMING':
|
||||||
case 'ACTIVE': return 'success'
|
return 'warning'
|
||||||
case 'ENDED': return 'info'
|
case 'ACTIVE':
|
||||||
default: return 'info'
|
return 'success'
|
||||||
|
case 'ENDED':
|
||||||
|
return 'info'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.data.status) {
|
switch (props.data.status) {
|
||||||
case 'DRAFT': return '草稿'
|
case 'DRAFT':
|
||||||
case 'UPCOMING': return '即将开始'
|
return '草稿'
|
||||||
case 'ACTIVE': return '拼团中'
|
case 'UPCOMING':
|
||||||
case 'ENDED': return '已结束'
|
return '即将开始'
|
||||||
default: return '未知'
|
case 'ACTIVE':
|
||||||
|
return '拼团中'
|
||||||
|
case 'ENDED':
|
||||||
|
return '已结束'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,7 +108,7 @@ const handleJoin = () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.group-buying-card {
|
.group-buying-card {
|
||||||
@apply bg-white rounded-2xl overflow-hidden;
|
@apply bg-white rounded-2xl overflow-hidden;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="group-member-list">
|
<div class="group-member-list">
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<div v-for="member in members" :key="member.userId" class="member-avatar" :title="member.username">
|
<div v-for="member in members" :key="member.userId" :title="member.username" class="member-avatar">
|
||||||
<el-avatar :size="40" :src="member.avatar">
|
<el-avatar :size="40" :src="member.avatar">
|
||||||
{{ member.username ? member.username[0] : '?' }}
|
{{ member.username ? member.username[0] : '?' }}
|
||||||
</el-avatar>
|
</el-avatar>
|
||||||
<span class="member-name">{{ member.username }}</span>
|
<span class="member-name">{{ member.username }}</span>
|
||||||
<el-tag v-if="member.userId === leaderUserId" size="small" type="warning" class="leader-tag">团长</el-tag>
|
<el-tag v-if="member.userId === leaderUserId" class="leader-tag" size="small" type="warning">团长</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="i in emptySlots" :key="'empty-' + i" class="member-avatar empty">
|
<div v-for="i in emptySlots" :key="'empty-' + i" class="member-avatar empty">
|
||||||
<div class="empty-slot">
|
<div class="empty-slot">
|
||||||
<el-icon :size="20"><Plus /></el-icon>
|
<el-icon :size="20">
|
||||||
|
<Plus/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<span class="member-name text-gray-400">等待加入</span>
|
<span class="member-name text-gray-400">等待加入</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,9 +20,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import {computed} from 'vue'
|
||||||
import type { GroupBuyingMember } from '@/types/api'
|
import type {GroupBuyingMember} from '@/types/api'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
members: GroupBuyingMember[]
|
members: GroupBuyingMember[]
|
||||||
@@ -31,7 +33,7 @@ const props = defineProps<{
|
|||||||
const emptySlots = computed(() => Math.max(0, props.requiredMembers - props.members.length))
|
const emptySlots = computed(() => Math.max(0, props.requiredMembers - props.members.length))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.member-avatar {
|
.member-avatar {
|
||||||
@apply flex flex-col items-center gap-1;
|
@apply flex flex-col items-center gap-1;
|
||||||
|
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
<div class="product-card card-shadow">
|
<div class="product-card card-shadow">
|
||||||
<div class="relative overflow-hidden h-48">
|
<div class="relative overflow-hidden h-48">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="data.imageUrl"
|
:alt="data.name"
|
||||||
:alt="data.name"
|
:src="data.imageUrl"
|
||||||
wrapper-class="w-full h-full"
|
img-class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
||||||
img-class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
wrapper-class="w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="data.stock <= 10" class="absolute top-2 right-2">
|
<div v-if="data.stock <= 10" class="absolute top-2 right-2">
|
||||||
<el-tag type="warning" size="small">
|
<el-tag size="small" type="warning">
|
||||||
仅剩 {{ data.stock }} 件
|
仅剩 {{ data.stock }} 件
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,21 +25,25 @@
|
|||||||
<span class="text-sm text-gray-400">库存: {{ data.stock }}</span>
|
<span class="text-sm text-gray-400">库存: {{ data.stock }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<el-button type="primary" size="small" class="flex-1" :disabled="data.stock === 0" @click="handleAddToCart">
|
<el-button :disabled="data.stock === 0" class="flex-1" size="small" type="primary" @click="handleAddToCart">
|
||||||
<el-icon class="mr-1"><ShoppingCart /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<ShoppingCart/>
|
||||||
|
</el-icon>
|
||||||
加入购物车
|
加入购物车
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" @click="handleViewDetail">
|
<el-button size="small" @click="handleViewDetail">
|
||||||
<el-icon><View /></el-icon>
|
<el-icon>
|
||||||
|
<View/>
|
||||||
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import type { Product } from '@/types/api'
|
import type {Product} from '@/types/api'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
const props = defineProps<{ data: Product }>()
|
const props = defineProps<{ data: Product }>()
|
||||||
@@ -57,7 +61,7 @@ const handleViewDetail = () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.product-card {
|
.product-card {
|
||||||
@apply bg-white rounded-2xl overflow-hidden;
|
@apply bg-white rounded-2xl overflow-hidden;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
title="申请退货"
|
title="申请退货"
|
||||||
width="520px"
|
width="520px"
|
||||||
@update:model-value="$emit('update:visible', $event)"
|
@update:model-value="$emit('update:visible', $event)"
|
||||||
>
|
>
|
||||||
<el-form :model="form" label-width="80px">
|
<el-form :model="form" label-width="80px">
|
||||||
<el-form-item label="退款金额">
|
<el-form-item label="退款金额">
|
||||||
@@ -12,22 +12,22 @@
|
|||||||
|
|
||||||
<el-form-item label="退货原因" required>
|
<el-form-item label="退货原因" required>
|
||||||
<el-select v-model="form.reason" placeholder="请选择退货原因" style="width: 100%">
|
<el-select v-model="form.reason" placeholder="请选择退货原因" style="width: 100%">
|
||||||
<el-option label="质量问题" value="质量问题" />
|
<el-option label="质量问题" value="质量问题"/>
|
||||||
<el-option label="商品与描述不符" value="商品与描述不符" />
|
<el-option label="商品与描述不符" value="商品与描述不符"/>
|
||||||
<el-option label="发错商品" value="发错商品" />
|
<el-option label="发错商品" value="发错商品"/>
|
||||||
<el-option label="不想要了" value="不想要了" />
|
<el-option label="不想要了" value="不想要了"/>
|
||||||
<el-option label="其他" value="其他" />
|
<el-option label="其他" value="其他"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="详细描述">
|
<el-form-item label="详细描述">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
type="textarea"
|
:rows="4"
|
||||||
:rows="4"
|
maxlength="500"
|
||||||
placeholder="请描述退货原因(选填)"
|
placeholder="请描述退货原因(选填)"
|
||||||
maxlength="500"
|
show-word-limit
|
||||||
show-word-limit
|
type="textarea"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
:disabled="!form.reason"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
:disabled="!form.reason"
|
type="primary"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
提交申请
|
提交申请
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch } from 'vue'
|
import {ref, reactive, watch} from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { returnApi } from '@/api/modules/return'
|
import {returnApi} from '@/api/modules/return'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
title="填写退货物流"
|
title="填写退货物流"
|
||||||
width="460px"
|
width="460px"
|
||||||
@update:model-value="$emit('update:visible', $event)"
|
@update:model-value="$emit('update:visible', $event)"
|
||||||
>
|
>
|
||||||
<el-form :model="form" label-width="90px">
|
<el-form :model="form" label-width="90px">
|
||||||
<el-form-item label="物流单号" required>
|
<el-form-item label="物流单号" required>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.returnTracking"
|
v-model="form.returnTracking"
|
||||||
placeholder="请输入退货物流单号"
|
clearable
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
clearable
|
placeholder="请输入退货物流单号"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
:disabled="!form.returnTracking.trim()"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
:disabled="!form.returnTracking.trim()"
|
type="primary"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
提交
|
提交
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch } from 'vue'
|
import {ref, reactive, watch} from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { returnApi } from '@/api/modules/return'
|
import {returnApi} from '@/api/modules/return'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
title="商品评价"
|
title="商品评价"
|
||||||
width="640px"
|
width="640px"
|
||||||
@update:model-value="$emit('update:visible', $event)"
|
@update:model-value="$emit('update:visible', $event)"
|
||||||
>
|
>
|
||||||
<div v-if="checkLoading" class="text-center py-8">
|
<div v-if="checkLoading" class="text-center py-8">
|
||||||
<el-icon :size="32" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="32" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载评价状态...</p>
|
<p class="mt-2 text-gray-500">加载评价状态...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
<div v-if="reviewableItems.length === 0 && reviewedItems.length === 0" class="text-center py-8">
|
<div v-if="reviewableItems.length === 0 && reviewedItems.length === 0" class="text-center py-8">
|
||||||
<el-empty description="暂无可评价商品" />
|
<el-empty description="暂无可评价商品"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 待评价商品 -->
|
<!-- 待评价商品 -->
|
||||||
<div v-for="item in reviewableItems" :key="item.productId" class="border rounded-lg p-4">
|
<div v-for="item in reviewableItems" :key="item.productId" class="border rounded-lg p-4">
|
||||||
<div class="flex gap-4 mb-4">
|
<div class="flex gap-4 mb-4">
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-16 h-16 rounded" img-class="w-16 h-16 object-cover rounded" />
|
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-16 h-16 object-cover rounded"
|
||||||
|
wrapper-class="w-16 h-16 rounded"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold">{{ item.productName }}</h4>
|
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||||
<div class="text-sm text-gray-500">¥{{ item.price }} × {{ item.quantity }}</div>
|
<div class="text-sm text-gray-500">¥{{ item.price }} × {{ item.quantity }}</div>
|
||||||
@@ -26,17 +29,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block text-sm text-gray-600 mb-1">评分</label>
|
<label class="block text-sm text-gray-600 mb-1">评分</label>
|
||||||
<el-rate v-model="item.rating" show-text :texts="['很差', '较差', '一般', '满意', '非常满意']" />
|
<el-rate v-model="item.rating" :texts="['很差', '较差', '一般', '满意', '非常满意']" show-text/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 mb-1">评价内容</label>
|
<label class="block text-sm text-gray-600 mb-1">评价内容</label>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="item.content"
|
v-model="item.content"
|
||||||
type="textarea"
|
:rows="3"
|
||||||
:rows="3"
|
maxlength="500"
|
||||||
placeholder="分享一下你的使用感受吧"
|
placeholder="分享一下你的使用感受吧"
|
||||||
maxlength="500"
|
show-word-limit
|
||||||
show-word-limit
|
type="textarea"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,13 +47,14 @@
|
|||||||
<!-- 已评价商品 -->
|
<!-- 已评价商品 -->
|
||||||
<div v-for="item in reviewedItems" :key="'reviewed-' + item.productId" class="border rounded-lg p-4 bg-gray-50">
|
<div v-for="item in reviewedItems" :key="'reviewed-' + item.productId" class="border rounded-lg p-4 bg-gray-50">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-16 h-16 rounded" img-class="w-16 h-16 object-cover rounded" />
|
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-16 h-16 object-cover rounded"
|
||||||
|
wrapper-class="w-16 h-16 rounded"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<h4 class="font-semibold">{{ item.productName }}</h4>
|
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||||
<el-tag type="success" size="small">已评价</el-tag>
|
<el-tag size="small" type="success">已评价</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-rate :model-value="item.existingReview!.rating" disabled />
|
<el-rate :model-value="item.existingReview!.rating" disabled/>
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ item.existingReview!.content }}</p>
|
<p class="text-sm text-gray-600 mt-1">{{ item.existingReview!.content }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,11 +64,11 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="reviewableItems.length > 0"
|
v-if="reviewableItems.length > 0"
|
||||||
type="primary"
|
:disabled="!canSubmit"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
:disabled="!canSubmit"
|
type="primary"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
提交评价
|
提交评价
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -72,12 +76,12 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import {ref, computed, watch} from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { reviewApi } from '@/api/modules/review'
|
import {reviewApi} from '@/api/modules/review'
|
||||||
import type { ReviewItem } from '@/api/modules/review'
|
import type {ReviewItem} from '@/api/modules/review'
|
||||||
import type { OrderItem } from '@/types/api'
|
import type {OrderItem} from '@/types/api'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
interface ReviewableItem extends OrderItem {
|
interface ReviewableItem extends OrderItem {
|
||||||
@@ -122,7 +126,7 @@ const loadReviewStatus = async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const checks = await Promise.all(
|
const checks = await Promise.all(
|
||||||
list.map(item => reviewApi.checkReview(props.orderId, item.productId).catch(() => null))
|
list.map(item => reviewApi.checkReview(props.orderId, item.productId).catch(() => null))
|
||||||
)
|
)
|
||||||
|
|
||||||
checks.forEach((res, index) => {
|
checks.forEach((res, index) => {
|
||||||
@@ -157,7 +161,7 @@ const handleSubmit = async () => {
|
|||||||
content: item.content.trim(),
|
content: item.content.trim(),
|
||||||
})
|
})
|
||||||
item.reviewed = true
|
item.reviewed = true
|
||||||
item.existingReview = { rating: item.rating, content: item.content } as ReviewItem
|
item.existingReview = {rating: item.rating, content: item.content} as ReviewItem
|
||||||
successCount++
|
successCount++
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const respData = error?.response?.data
|
const respData = error?.response?.data
|
||||||
@@ -184,10 +188,10 @@ watch(() => props.visible, (val) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.orderId, props.orderItems],
|
() => [props.orderId, props.orderItems],
|
||||||
() => {
|
() => {
|
||||||
if (props.visible) loadReviewStatus()
|
if (props.visible) loadReviewStatus()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{immediate: true}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
社区生鲜团购平台,提供商品浏览、拼团下单和订单管理服务。
|
社区生鲜团购平台,提供商品浏览、拼团下单和订单管理服务。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快速链接 -->
|
<!-- 快速链接 -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
|
<h3 class="text-lg font-semibold mb-4">快速链接</h3>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<router-link to="/" class="footer-link">
|
<router-link class="footer-link" to="/">
|
||||||
首页
|
首页
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link to="/products" class="footer-link">
|
<router-link class="footer-link" to="/products">
|
||||||
商品列表
|
商品列表
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 技术栈 -->
|
<!-- 技术栈 -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold mb-4">技术栈</h3>
|
<h3 class="text-lg font-semibold mb-4">技术栈</h3>
|
||||||
@@ -44,23 +44,27 @@
|
|||||||
<span class="tech-tag">TailwindCSS</span>
|
<span class="tech-tag">TailwindCSS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 联系我们 -->
|
<!-- 联系我们 -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold mb-4">联系我们</h3>
|
<h3 class="text-lg font-semibold mb-4">联系我们</h3>
|
||||||
<div class="space-y-2 text-gray-600">
|
<div class="space-y-2 text-gray-600">
|
||||||
<p class="flex items-center">
|
<p class="flex items-center">
|
||||||
<el-icon class="mr-2"><Message /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<Message/>
|
||||||
|
</el-icon>
|
||||||
service@freshgroup.com
|
service@freshgroup.com
|
||||||
</p>
|
</p>
|
||||||
<p class="flex items-center">
|
<p class="flex items-center">
|
||||||
<el-icon class="mr-2"><Phone /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<Phone/>
|
||||||
|
</el-icon>
|
||||||
400-123-4567
|
400-123-4567
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t mt-8 pt-8 text-center text-gray-500 text-sm">
|
<div class="border-t mt-8 pt-8 text-center text-gray-500 text-sm">
|
||||||
<p>© 社区生鲜团购平台. All rights reserved.</p>
|
<p>© 社区生鲜团购平台. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,10 +72,10 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.app-footer {
|
.app-footer {
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border-top: 1px solid #d8cebf;
|
border-top: 1px solid #d8cebf;
|
||||||
@@ -3,48 +3,58 @@
|
|||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<div class="header-brand">
|
<div class="header-brand">
|
||||||
<router-link to="/" class="brand-link">
|
<router-link class="brand-link" to="/">
|
||||||
<el-icon :size="24" class="brand-icon">
|
<el-icon :size="24" class="brand-icon">
|
||||||
<Lightning />
|
<Lightning/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span class="brand-title">社区生鲜团购系统</span>
|
<span class="brand-title">社区生鲜团购系统</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-links hidden xl:flex items-center">
|
<div class="header-links hidden xl:flex items-center">
|
||||||
<router-link to="/" class="nav-link">
|
<router-link class="nav-link" to="/">
|
||||||
<el-icon><HomeFilled /></el-icon>
|
<el-icon>
|
||||||
|
<HomeFilled/>
|
||||||
|
</el-icon>
|
||||||
首页
|
首页
|
||||||
</router-link>
|
</router-link>
|
||||||
<el-dropdown trigger="hover" @command="handleCategoryCommand">
|
<el-dropdown trigger="hover" @command="handleCategoryCommand">
|
||||||
<router-link to="/products" class="nav-link">
|
<router-link class="nav-link" to="/products">
|
||||||
<el-icon><ShoppingBag /></el-icon>
|
<el-icon>
|
||||||
|
<ShoppingBag/>
|
||||||
|
</el-icon>
|
||||||
商品列表
|
商品列表
|
||||||
<el-icon class="ml-1" :size="12"><ArrowDown /></el-icon>
|
<el-icon :size="12" class="ml-1">
|
||||||
|
<ArrowDown/>
|
||||||
|
</el-icon>
|
||||||
</router-link>
|
</router-link>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="">全部商品</el-dropdown-item>
|
<el-dropdown-item command="">全部商品</el-dropdown-item>
|
||||||
<el-dropdown-item
|
<el-dropdown-item
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
:command="cat"
|
:command="cat"
|
||||||
>
|
>
|
||||||
{{ cat }}
|
{{ cat }}
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<router-link to="/groupbuying" class="nav-link">
|
<router-link class="nav-link" to="/groupbuying">
|
||||||
<el-icon><Connection /></el-icon>
|
<el-icon>
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
拼团
|
拼团
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-dropdown class="header-menu xl:hidden" trigger="click" @command="handleMainNavCommand">
|
<el-dropdown class="header-menu xl:hidden" trigger="click" @command="handleMainNavCommand">
|
||||||
<button type="button" class="menu-trigger" aria-label="打开导航菜单">
|
<button aria-label="打开导航菜单" class="menu-trigger" type="button">
|
||||||
<el-icon :size="18"><Menu /></el-icon>
|
<el-icon :size="18">
|
||||||
|
<Menu/>
|
||||||
|
</el-icon>
|
||||||
<span class="hidden sm:inline">菜单</span>
|
<span class="hidden sm:inline">菜单</span>
|
||||||
</button>
|
</button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@@ -57,14 +67,16 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
|
||||||
<div class="header-search hidden lg:block">
|
<div class="header-search hidden lg:block">
|
||||||
<SearchComponent />
|
<SearchComponent/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationCenter v-if="userStore.isLoggedIn && !userStore.isAdmin"/>
|
<NotificationCenter v-if="userStore.isLoggedIn && !userStore.isAdmin"/>
|
||||||
|
|
||||||
<router-link v-if="!userStore.isAdmin" class="cart-link relative" to="/cart">
|
<router-link v-if="!userStore.isAdmin" class="cart-link relative" to="/cart">
|
||||||
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
|
<el-badge :hidden="cartCount === 0" :value="cartCount" class="cart-badge">
|
||||||
<el-icon :size="20"><ShoppingCart /></el-icon>
|
<el-icon :size="20">
|
||||||
|
<ShoppingCart/>
|
||||||
|
</el-icon>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
@@ -79,31 +91,45 @@
|
|||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/profile')">
|
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/profile')">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon>
|
||||||
|
<User/>
|
||||||
|
</el-icon>
|
||||||
个人中心
|
个人中心
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/orders')">
|
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/orders')">
|
||||||
<el-icon><List /></el-icon>
|
<el-icon>
|
||||||
|
<List/>
|
||||||
|
</el-icon>
|
||||||
我的订单
|
我的订单
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/favorites')">
|
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/favorites')">
|
||||||
<el-icon><Star /></el-icon>
|
<el-icon>
|
||||||
|
<Star/>
|
||||||
|
</el-icon>
|
||||||
我的收藏
|
我的收藏
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/reviews')">
|
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/reviews')">
|
||||||
<el-icon><ChatDotRound /></el-icon>
|
<el-icon>
|
||||||
|
<ChatDotRound/>
|
||||||
|
</el-icon>
|
||||||
我的评价
|
我的评价
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/notifications')">
|
<el-dropdown-item v-if="!userStore.isAdmin" @click="router.push('/notifications')">
|
||||||
<el-icon><Bell /></el-icon>
|
<el-icon>
|
||||||
|
<Bell/>
|
||||||
|
</el-icon>
|
||||||
消息通知
|
消息通知
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
|
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon>
|
||||||
|
<Setting/>
|
||||||
|
</el-icon>
|
||||||
管理后台
|
管理后台
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item divided @click="handleLogout">
|
<el-dropdown-item divided @click="handleLogout">
|
||||||
<el-icon><SwitchButton /></el-icon>
|
<el-icon>
|
||||||
|
<SwitchButton/>
|
||||||
|
</el-icon>
|
||||||
退出登录
|
退出登录
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
@@ -121,15 +147,15 @@
|
|||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import {ref, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import NotificationCenter from './NotificationCenter.vue'
|
import NotificationCenter from './NotificationCenter.vue'
|
||||||
import SearchComponent from './SearchComponent.vue'
|
import SearchComponent from './SearchComponent.vue'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import {ElMessageBox} from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -153,7 +179,7 @@ const loadCategories = async () => {
|
|||||||
// 分类下拉菜单点击
|
// 分类下拉菜单点击
|
||||||
const handleCategoryCommand = (category: string) => {
|
const handleCategoryCommand = (category: string) => {
|
||||||
if (category) {
|
if (category) {
|
||||||
router.push({ path: '/products', query: { category } })
|
router.push({path: '/products', query: {category}})
|
||||||
} else {
|
} else {
|
||||||
router.push('/products')
|
router.push('/products')
|
||||||
}
|
}
|
||||||
@@ -172,7 +198,7 @@ const handleLogout = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
|
|
||||||
await userStore.logout()
|
await userStore.logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +215,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.app-header {
|
.app-header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -281,11 +307,11 @@ onMounted(() => {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.25s ease;
|
transition: color 0.25s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.router-link-active {
|
&.router-link-active {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1,59 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="image-upload">
|
<div class="image-upload">
|
||||||
<el-upload
|
<el-upload
|
||||||
:class="{ 'hide-upload': fileList.length >= limit }"
|
:accept="accept"
|
||||||
:action="uploadUrl"
|
:action="uploadUrl"
|
||||||
:headers="headers"
|
:before-upload="beforeUpload"
|
||||||
:with-credentials="true"
|
:class="{ 'hide-upload': fileList.length >= limit }"
|
||||||
:file-list="fileList"
|
:file-list="fileList"
|
||||||
:limit="limit"
|
:headers="headers"
|
||||||
:multiple="multiple"
|
:limit="limit"
|
||||||
:accept="accept"
|
:multiple="multiple"
|
||||||
:before-upload="beforeUpload"
|
:on-error="handleError"
|
||||||
:on-preview="handlePreview"
|
:on-exceed="handleExceed"
|
||||||
:on-remove="handleRemove"
|
:on-preview="handlePreview"
|
||||||
:on-success="handleSuccess"
|
:on-remove="handleRemove"
|
||||||
:on-error="handleError"
|
:on-success="handleSuccess"
|
||||||
:on-exceed="handleExceed"
|
:with-credentials="true"
|
||||||
list-type="picture-card"
|
list-type="picture-card"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
<el-icon class="upload-icon">
|
||||||
|
<Plus/>
|
||||||
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
<template #file="{ file }">
|
<template #file="{ file }">
|
||||||
<div class="upload-file-item">
|
<div class="upload-file-item">
|
||||||
<img class="upload-image" :src="file.url" :alt="file.name" />
|
<img :alt="file.name" :src="file.url" class="upload-image"/>
|
||||||
<span class="upload-actions">
|
<span class="upload-actions">
|
||||||
<span class="upload-action" @click="handlePreview(file)">
|
<span class="upload-action" @click="handlePreview(file)">
|
||||||
<el-icon><ZoomIn /></el-icon>
|
<el-icon><ZoomIn/></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-action" @click="handleDownload(file)">
|
<span class="upload-action" @click="handleDownload(file)">
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download/></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-action" @click="handleRemove(file)">
|
<span class="upload-action" @click="handleRemove(file)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete/></el-icon>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<!-- 上传进度 -->
|
<!-- 上传进度 -->
|
||||||
<el-progress
|
<el-progress
|
||||||
v-if="file.status === 'uploading'"
|
v-if="file.status === 'uploading'"
|
||||||
type="circle"
|
:percentage="file.percentage"
|
||||||
:percentage="file.percentage"
|
class="upload-progress"
|
||||||
class="upload-progress"
|
type="circle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
<!-- 图片预览 -->
|
<!-- 图片预览 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="previewVisible"
|
v-model="previewVisible"
|
||||||
title="图片预览"
|
append-to-body
|
||||||
width="800px"
|
title="图片预览"
|
||||||
append-to-body
|
width="800px"
|
||||||
>
|
>
|
||||||
<div class="preview-container">
|
<div class="preview-container">
|
||||||
<img :src="previewUrl" :alt="previewName" class="preview-image" />
|
<img :alt="previewName" :src="previewUrl" class="preview-image"/>
|
||||||
<div class="preview-info">
|
<div class="preview-info">
|
||||||
<p><strong>文件名:</strong>{{ previewName }}</p>
|
<p><strong>文件名:</strong>{{ previewName }}</p>
|
||||||
<p><strong>文件大小:</strong>{{ formatFileSize(previewSize) }}</p>
|
<p><strong>文件大小:</strong>{{ formatFileSize(previewSize) }}</p>
|
||||||
@@ -63,12 +65,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import {ref, computed, watch} from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { resolveImageUrl } from '@/utils/image'
|
import {resolveImageUrl} from '@/utils/image'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { UploadFile, UploadRawFile } from 'element-plus'
|
import type {UploadFile, UploadRawFile} from 'element-plus'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: string | string[]
|
modelValue?: string | string[]
|
||||||
@@ -126,24 +128,24 @@ const getRawUrl = (file: UploadFile) => {
|
|||||||
|
|
||||||
// 初始化文件列表
|
// 初始化文件列表
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
const nextFiles = Array.isArray(val)
|
const nextFiles = Array.isArray(val)
|
||||||
? val
|
? val
|
||||||
.map((url) => String(url || '').trim())
|
.map((url) => String(url || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((url, index) => buildFileItem(url, index))
|
.map((url, index) => buildFileItem(url, index))
|
||||||
: val && String(val).trim()
|
: val && String(val).trim()
|
||||||
? [buildFileItem(String(val).trim(), 0)]
|
? [buildFileItem(String(val).trim(), 0)]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const currentRawUrls = fileList.value.map((file) => getRawUrl(file))
|
const currentRawUrls = fileList.value.map((file) => getRawUrl(file))
|
||||||
const nextRawUrls = nextFiles.map((file) => getRawUrl(file))
|
const nextRawUrls = nextFiles.map((file) => getRawUrl(file))
|
||||||
if (JSON.stringify(currentRawUrls) !== JSON.stringify(nextRawUrls)) {
|
if (JSON.stringify(currentRawUrls) !== JSON.stringify(nextRawUrls)) {
|
||||||
fileList.value = nextFiles
|
fileList.value = nextFiles
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{immediate: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 格式化文件大小
|
// 格式化文件大小
|
||||||
@@ -165,13 +167,13 @@ const beforeUpload = (rawFile: UploadRawFile) => {
|
|||||||
ElMessage.error('只能上传图片文件!')
|
ElMessage.error('只能上传图片文件!')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小
|
// 验证文件大小
|
||||||
if (rawFile.size / 1024 / 1024 > props.maxSize) {
|
if (rawFile.size / 1024 / 1024 > props.maxSize) {
|
||||||
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB!`)
|
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB!`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证图片宽高比(可选)
|
// 验证图片宽高比(可选)
|
||||||
if (props.aspectRatio) {
|
if (props.aspectRatio) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -182,7 +184,7 @@ const beforeUpload = (rawFile: UploadRawFile) => {
|
|||||||
const ratio = img.width / img.height
|
const ratio = img.width / img.height
|
||||||
const targetRatio = props.aspectRatio!
|
const targetRatio = props.aspectRatio!
|
||||||
const tolerance = 0.1 // 允许10%的误差
|
const tolerance = 0.1 // 允许10%的误差
|
||||||
|
|
||||||
if (Math.abs(ratio - targetRatio) > tolerance) {
|
if (Math.abs(ratio - targetRatio) > tolerance) {
|
||||||
ElMessage.error(`图片宽高比不符合要求(需要 ${targetRatio}:1)`)
|
ElMessage.error(`图片宽高比不符合要求(需要 ${targetRatio}:1)`)
|
||||||
reject(false)
|
reject(false)
|
||||||
@@ -195,7 +197,7 @@ const beforeUpload = (rawFile: UploadRawFile) => {
|
|||||||
reader.readAsDataURL(rawFile)
|
reader.readAsDataURL(rawFile)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,17 +273,17 @@ const handleRemove = (file: UploadFile) => {
|
|||||||
// 更新值
|
// 更新值
|
||||||
const updateValue = () => {
|
const updateValue = () => {
|
||||||
const urls = fileList.value
|
const urls = fileList.value
|
||||||
.filter(f => f.status === 'success')
|
.filter(f => f.status === 'success')
|
||||||
.map(f => getRawUrl(f))
|
.map(f => getRawUrl(f))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
const value = props.multiple ? urls : urls[0] || ''
|
const value = props.multiple ? urls : urls[0] || ''
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
emit('change', value)
|
emit('change', value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.image-upload {
|
.image-upload {
|
||||||
:deep(.el-upload-list--picture-card) {
|
:deep(.el-upload-list--picture-card) {
|
||||||
.el-upload-list__item {
|
.el-upload-list__item {
|
||||||
@@ -289,34 +291,34 @@ const updateValue = () => {
|
|||||||
height: 120px;
|
height: 120px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-upload--picture-card) {
|
:deep(.el-upload--picture-card) {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: #8c939d;
|
color: #8c939d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hide-upload {
|
&.hide-upload {
|
||||||
:deep(.el-upload--picture-card) {
|
:deep(.el-upload--picture-card) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-file-item {
|
.upload-file-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.upload-image {
|
.upload-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-actions {
|
.upload-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -334,20 +336,20 @@ const updateValue = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
.upload-action {
|
.upload-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .upload-actions {
|
&:hover .upload-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress {
|
.upload-progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -363,13 +365,13 @@ const updateValue = () => {
|
|||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-info {
|
.preview-info {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #f5f7fa;
|
background-color: #f5f7fa;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1,47 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="notification-center">
|
<div class="notification-center">
|
||||||
<el-popover
|
<el-popover
|
||||||
placement="bottom-end"
|
:visible="visible"
|
||||||
:width="380"
|
:width="380"
|
||||||
trigger="click"
|
placement="bottom-end"
|
||||||
:visible="visible"
|
trigger="click"
|
||||||
@update:visible="val => visible = val"
|
@update:visible="val => visible = val"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div class="notification-trigger">
|
<div class="notification-trigger">
|
||||||
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
|
<el-badge :hidden="unreadCount === 0" :value="unreadCount">
|
||||||
<el-icon :size="20"><Bell /></el-icon>
|
<el-icon :size="20">
|
||||||
|
<Bell/>
|
||||||
|
</el-icon>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="notification-content">
|
<div class="notification-content">
|
||||||
<!-- 标题栏 -->
|
<!-- 标题栏 -->
|
||||||
<div class="notification-header">
|
<div class="notification-header">
|
||||||
<span class="title">消息通知</span>
|
<span class="title">消息通知</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button text size="small" @click="markAllAsRead">
|
<el-button size="small" text @click="markAllAsRead">
|
||||||
全部已读
|
全部已读
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button text size="small" @click="clearAll">
|
<el-button size="small" text @click="clearAll">
|
||||||
清空
|
清空
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签页 -->
|
<!-- 标签页 -->
|
||||||
<el-tabs v-model="activeTab" class="notification-tabs">
|
<el-tabs v-model="activeTab" class="notification-tabs">
|
||||||
<el-tab-pane label="全部" name="all">
|
<el-tab-pane label="全部" name="all">
|
||||||
<div class="notification-list">
|
<div class="notification-list">
|
||||||
<div
|
<div
|
||||||
v-for="item in allNotifications"
|
v-for="item in allNotifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="notification-item"
|
:class="{ unread: !item.read }"
|
||||||
:class="{ unread: !item.read }"
|
class="notification-item"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" :class="getIconClass(item.type)">
|
<el-icon :class="getIconClass(item.type)" :size="16">
|
||||||
<component :is="getIcon(item.type)" />
|
<component :is="getIcon(item.type)"/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
@@ -49,30 +51,30 @@
|
|||||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!item.read"
|
v-if="!item.read"
|
||||||
text
|
size="small"
|
||||||
size="small"
|
text
|
||||||
@click.stop="markAsRead(item.id)"
|
@click.stop="markAsRead(item.id)"
|
||||||
>
|
>
|
||||||
标记已读
|
标记已读
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-empty v-if="allNotifications.length === 0" description="暂无消息" />
|
<el-empty v-if="allNotifications.length === 0" description="暂无消息"/>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="限时" name="flashsale">
|
<el-tab-pane label="限时" name="flashsale">
|
||||||
<div class="notification-list">
|
<div class="notification-list">
|
||||||
<div
|
<div
|
||||||
v-for="item in flashsaleNotifications"
|
v-for="item in flashsaleNotifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="notification-item"
|
:class="{ unread: !item.read }"
|
||||||
:class="{ unread: !item.read }"
|
class="notification-item"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" class="notification-icon">
|
<el-icon :size="16" class="notification-icon">
|
||||||
<Lightning />
|
<Lightning/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
@@ -84,18 +86,18 @@
|
|||||||
<el-empty v-if="flashsaleNotifications.length === 0" description="暂无限时消息"/>
|
<el-empty v-if="flashsaleNotifications.length === 0" description="暂无限时消息"/>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="订单" name="order">
|
<el-tab-pane label="订单" name="order">
|
||||||
<div class="notification-list">
|
<div class="notification-list">
|
||||||
<div
|
<div
|
||||||
v-for="item in orderNotifications"
|
v-for="item in orderNotifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="notification-item"
|
:class="{ unread: !item.read }"
|
||||||
:class="{ unread: !item.read }"
|
class="notification-item"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" class="notification-icon">
|
<el-icon :size="16" class="notification-icon">
|
||||||
<List />
|
<List/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
@@ -103,22 +105,22 @@
|
|||||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-empty v-if="orderNotifications.length === 0" description="暂无订单消息" />
|
<el-empty v-if="orderNotifications.length === 0" description="暂无订单消息"/>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="系统" name="system">
|
<el-tab-pane label="系统" name="system">
|
||||||
<div class="notification-list">
|
<div class="notification-list">
|
||||||
<div
|
<div
|
||||||
v-for="item in systemNotifications"
|
v-for="item in systemNotifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="notification-item"
|
:class="{ unread: !item.read }"
|
||||||
:class="{ unread: !item.read }"
|
class="notification-item"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" class="text-gray-500">
|
<el-icon :size="16" class="text-gray-500">
|
||||||
<InfoFilled />
|
<InfoFilled/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
@@ -126,15 +128,15 @@
|
|||||||
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
<div class="time">{{ formatTime(item.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-empty v-if="systemNotifications.length === 0" description="暂无系统消息" />
|
<el-empty v-if="systemNotifications.length === 0" description="暂无系统消息"/>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<!-- 底部 -->
|
<!-- 底部 -->
|
||||||
<div class="notification-footer">
|
<div class="notification-footer">
|
||||||
<router-link to="/notifications" class="view-all">
|
<router-link class="view-all" to="/notifications">
|
||||||
查看全部消息
|
查看全部消息
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +145,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { notificationApi } from '@/api/modules/notification'
|
import {notificationApi} from '@/api/modules/notification'
|
||||||
import type { NotificationItem } from '@/api/modules/notification'
|
import type {NotificationItem} from '@/api/modules/notification'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import 'dayjs/locale/zh-cn'
|
import 'dayjs/locale/zh-cn'
|
||||||
@@ -167,21 +169,21 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
|
|||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const unreadCount = computed(() =>
|
const unreadCount = computed(() =>
|
||||||
notifications.value.filter(n => !n.read).length
|
notifications.value.filter(n => !n.read).length
|
||||||
)
|
)
|
||||||
|
|
||||||
const allNotifications = computed(() => notifications.value)
|
const allNotifications = computed(() => notifications.value)
|
||||||
|
|
||||||
const flashsaleNotifications = computed(() =>
|
const flashsaleNotifications = computed(() =>
|
||||||
notifications.value.filter(n => n.type === 'flashsale')
|
notifications.value.filter(n => n.type === 'flashsale')
|
||||||
)
|
)
|
||||||
|
|
||||||
const orderNotifications = computed(() =>
|
const orderNotifications = computed(() =>
|
||||||
notifications.value.filter(n => n.type === 'order')
|
notifications.value.filter(n => n.type === 'order')
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemNotifications = computed(() =>
|
const systemNotifications = computed(() =>
|
||||||
notifications.value.filter(n => n.type === 'system')
|
notifications.value.filter(n => n.type === 'system')
|
||||||
)
|
)
|
||||||
|
|
||||||
// 从后端加载通知
|
// 从后端加载通知
|
||||||
@@ -279,7 +281,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.notification-center {
|
.notification-center {
|
||||||
.notification-trigger {
|
.notification-trigger {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -293,7 +295,7 @@ onUnmounted(() => {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #2b2b27;
|
color: #2b2b27;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
}
|
}
|
||||||
@@ -315,33 +317,33 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid #e4e7ed;
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-tabs {
|
.notification-tabs {
|
||||||
:deep(.el-tabs__header) {
|
:deep(.el-tabs__header) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-tabs__content) {
|
:deep(.el-tabs__content) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-list {
|
.notification-list {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
.notification-item {
|
.notification-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -350,29 +352,29 @@ onUnmounted(() => {
|
|||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f7f7f6;
|
background-color: #f7f7f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unread {
|
&.unread {
|
||||||
background-color: #f7f7f6;
|
background-color: #f7f7f6;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
@@ -381,7 +383,7 @@ onUnmounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #c0c4cc;
|
color: #c0c4cc;
|
||||||
@@ -389,17 +391,17 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-footer {
|
.notification-footer {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid #e4e7ed;
|
border-top: 1px solid #e4e7ed;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.view-all {
|
.view-all {
|
||||||
color: #44443f;
|
color: #44443f;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
}
|
}
|
||||||
@@ -4,19 +4,19 @@
|
|||||||
<div class="safe-image__shimmer"></div>
|
<div class="safe-image__shimmer"></div>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
:src="currentSrc"
|
:alt="alt"
|
||||||
:alt="alt"
|
:class="['safe-image__img', imgClass, { 'is-loaded': loaded }]"
|
||||||
:class="['safe-image__img', imgClass, { 'is-loaded': loaded }]"
|
:loading="lazy ? 'lazy' : 'eager'"
|
||||||
:loading="lazy ? 'lazy' : 'eager'"
|
:src="currentSrc"
|
||||||
@load="handleLoad"
|
@error="handleError"
|
||||||
@error="handleError"
|
@load="handleLoad"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from 'vue'
|
import {ref, watch} from 'vue'
|
||||||
import { DEFAULT_PRODUCT_IMAGE, resolveImageUrl } from '@/utils/image'
|
import {DEFAULT_PRODUCT_IMAGE, resolveImageUrl} from '@/utils/image'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src?: string | null
|
src?: string | null
|
||||||
@@ -49,7 +49,7 @@ const resetSource = () => {
|
|||||||
fallbackApplied.value = false
|
fallbackApplied.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.src, resetSource, { immediate: true })
|
watch(() => props.src, resetSource, {immediate: true})
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
@@ -72,7 +72,7 @@ const handleClick = () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.safe-image {
|
.safe-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -91,7 +91,7 @@ const handleClick = () => {
|
|||||||
&__shimmer {
|
&__shimmer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.06) 50%, rgba(255,255,255,0) 100%);
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(0, 0, 0, 0.06) 50%, rgba(255, 255, 255, 0) 100%);
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,59 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-component">
|
<div class="search-component">
|
||||||
<el-popover
|
<el-popover
|
||||||
placement="bottom"
|
:visible="visible"
|
||||||
:width="600"
|
:width="600"
|
||||||
trigger="click"
|
placement="bottom"
|
||||||
:visible="visible"
|
trigger="click"
|
||||||
@update:visible="val => visible = val"
|
@update:visible="val => visible = val"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索商品、限时活动..."
|
class="search-input"
|
||||||
class="search-input"
|
placeholder="搜索商品、限时活动..."
|
||||||
@keyup.enter="handleQuickSearch"
|
@focus="handleFocus"
|
||||||
@focus="handleFocus"
|
@keyup.enter="handleQuickSearch"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon>
|
||||||
|
<Search/>
|
||||||
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="searchQuery"
|
v-if="searchQuery"
|
||||||
text
|
circle
|
||||||
circle
|
size="small"
|
||||||
size="small"
|
text
|
||||||
@click.stop="clearSearch"
|
@click.stop="clearSearch"
|
||||||
>
|
>
|
||||||
<el-icon><Close /></el-icon>
|
<el-icon>
|
||||||
|
<Close/>
|
||||||
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="search-panel">
|
<div class="search-panel">
|
||||||
<!-- 搜索历史 -->
|
<!-- 搜索历史 -->
|
||||||
<div v-if="!searchQuery && searchHistory.length > 0" class="search-section">
|
<div v-if="!searchQuery && searchHistory.length > 0" class="search-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="title">搜索历史</span>
|
<span class="title">搜索历史</span>
|
||||||
<el-button text size="small" @click="clearHistory">
|
<el-button size="small" text @click="clearHistory">
|
||||||
清空
|
清空
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="item in searchHistory"
|
v-for="item in searchHistory"
|
||||||
:key="item"
|
:key="item"
|
||||||
closable
|
closable
|
||||||
@click="selectHistory(item)"
|
@click="selectHistory(item)"
|
||||||
@close="removeHistory(item)"
|
@close="removeHistory(item)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 热门搜索 -->
|
<!-- 热门搜索 -->
|
||||||
<div v-if="!searchQuery" class="search-section">
|
<div v-if="!searchQuery" class="search-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -61,15 +65,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="item in hotSearches"
|
v-for="item in hotSearches"
|
||||||
:key="item"
|
:key="item"
|
||||||
@click="selectHot(item)"
|
@click="selectHot(item)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 搜索建议 -->
|
<!-- 搜索建议 -->
|
||||||
<div v-if="searchQuery && suggestions.length > 0" class="search-suggestions">
|
<div v-if="searchQuery && suggestions.length > 0" class="search-suggestions">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -77,13 +81,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="suggestion-list">
|
<div class="suggestion-list">
|
||||||
<div
|
<div
|
||||||
v-for="item in suggestions"
|
v-for="item in suggestions"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="suggestion-item"
|
class="suggestion-item"
|
||||||
@click="selectSuggestion(item)"
|
@click="selectSuggestion(item)"
|
||||||
>
|
>
|
||||||
<el-icon :size="16" class="mr-2">
|
<el-icon :size="16" class="mr-2">
|
||||||
<component :is="item.type === 'product' ? 'ShoppingBag' : 'Lightning'" />
|
<component :is="item.type === 'product' ? 'ShoppingBag' : 'Lightning'"/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="name" v-html="highlightKeyword(item.name)"></div>
|
<div class="name" v-html="highlightKeyword(item.name)"></div>
|
||||||
@@ -95,14 +99,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 高级搜索 -->
|
<!-- 高级搜索 -->
|
||||||
<div class="advanced-search">
|
<div class="advanced-search">
|
||||||
<el-collapse v-model="activeCollapse">
|
<el-collapse v-model="activeCollapse">
|
||||||
<el-collapse-item name="advanced">
|
<el-collapse-item name="advanced">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="search-advanced-title">
|
<span class="search-advanced-title">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon><Setting/></el-icon>
|
||||||
高级搜索
|
高级搜索
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -110,23 +114,23 @@
|
|||||||
<el-form :model="advancedForm" label-width="80px" size="small">
|
<el-form :model="advancedForm" label-width="80px" size="small">
|
||||||
<el-form-item label="商品分类">
|
<el-form-item label="商品分类">
|
||||||
<el-select v-model="advancedForm.category" placeholder="选择分类">
|
<el-select v-model="advancedForm.category" placeholder="选择分类">
|
||||||
<el-option label="全部分类" value="" />
|
<el-option label="全部分类" value=""/>
|
||||||
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
|
<el-option v-for="item in categories" :key="item" :label="item" :value="item"/>
|
||||||
<el-option label="图书音像" value="books" />
|
<el-option label="图书音像" value="books"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="价格区间">
|
<el-form-item label="价格区间">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="advancedForm.minPrice"
|
v-model="advancedForm.minPrice"
|
||||||
:min="0"
|
:min="0"
|
||||||
placeholder="最低价"
|
placeholder="最低价"
|
||||||
/>
|
/>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="advancedForm.maxPrice"
|
v-model="advancedForm.maxPrice"
|
||||||
:min="0"
|
:min="0"
|
||||||
placeholder="最高价"
|
placeholder="最高价"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -152,12 +156,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch, onMounted } from 'vue'
|
import {ref, reactive, watch, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { debounce } from 'lodash-es'
|
import {debounce} from 'lodash-es'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
import {flashsaleApi} from '@/api/modules/flashsale'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -201,12 +205,12 @@ const loadSearchHistory = () => {
|
|||||||
// 保存搜索历史
|
// 保存搜索历史
|
||||||
const saveSearchHistory = (keyword: string) => {
|
const saveSearchHistory = (keyword: string) => {
|
||||||
if (!keyword.trim()) return
|
if (!keyword.trim()) return
|
||||||
|
|
||||||
// 去重并限制数量
|
// 去重并限制数量
|
||||||
const history = searchHistory.value.filter(item => item !== keyword)
|
const history = searchHistory.value.filter(item => item !== keyword)
|
||||||
history.unshift(keyword)
|
history.unshift(keyword)
|
||||||
searchHistory.value = history.slice(0, 10)
|
searchHistory.value = history.slice(0, 10)
|
||||||
|
|
||||||
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
|
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +252,7 @@ const selectSuggestion = (item: any) => {
|
|||||||
// 高亮关键词
|
// 高亮关键词
|
||||||
const highlightKeyword = (text: string) => {
|
const highlightKeyword = (text: string) => {
|
||||||
if (!searchQuery.value) return text
|
if (!searchQuery.value) return text
|
||||||
|
|
||||||
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
||||||
return text.replace(regex, '<span class="search-highlight">$1</span>')
|
return text.replace(regex, '<span class="search-highlight">$1</span>')
|
||||||
}
|
}
|
||||||
@@ -262,30 +266,30 @@ const fetchSuggestions = debounce(async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [productRes, flashSaleRes] = await Promise.all([
|
const [productRes, flashSaleRes] = await Promise.all([
|
||||||
productApi.getList({ keyword: searchQuery.value, page: 0, size: 5 }),
|
productApi.getList({keyword: searchQuery.value, page: 0, size: 5}),
|
||||||
flashsaleApi.getList({ page: 0, size: 6 }),
|
flashsaleApi.getList({page: 0, size: 6}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const productSuggestions = productRes.success
|
const productSuggestions = productRes.success
|
||||||
? productRes.data.content.map((item) => ({
|
? productRes.data.content.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: 'product',
|
type: 'product',
|
||||||
name: item.name,
|
name: item.name,
|
||||||
price: item.price,
|
price: item.price,
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const flashSaleSuggestions = flashSaleRes.success
|
const flashSaleSuggestions = flashSaleRes.success
|
||||||
? flashSaleRes.data.content
|
? flashSaleRes.data.content
|
||||||
.filter((item) => item.productName.includes(searchQuery.value))
|
.filter((item) => item.productName.includes(searchQuery.value))
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: 'flashsale',
|
type: 'flashsale',
|
||||||
name: item.productName,
|
name: item.productName,
|
||||||
price: item.flashPrice,
|
price: item.flashPrice,
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
suggestions.value = [...productSuggestions, ...flashSaleSuggestions].slice(0, 8)
|
suggestions.value = [...productSuggestions, ...flashSaleSuggestions].slice(0, 8)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -296,11 +300,11 @@ const fetchSuggestions = debounce(async () => {
|
|||||||
// 快速搜索
|
// 快速搜索
|
||||||
const handleQuickSearch = () => {
|
const handleQuickSearch = () => {
|
||||||
if (!searchQuery.value.trim()) return
|
if (!searchQuery.value.trim()) return
|
||||||
|
|
||||||
saveSearchHistory(searchQuery.value)
|
saveSearchHistory(searchQuery.value)
|
||||||
router.push({
|
router.push({
|
||||||
path: '/products',
|
path: '/products',
|
||||||
query: { keyword: searchQuery.value }
|
query: {keyword: searchQuery.value}
|
||||||
})
|
})
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
@@ -310,11 +314,11 @@ const handleAdvancedSearch = () => {
|
|||||||
const params: any = {
|
const params: any = {
|
||||||
keyword: searchQuery.value
|
keyword: searchQuery.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (advancedForm.category) params.category = advancedForm.category
|
if (advancedForm.category) params.category = advancedForm.category
|
||||||
if (advancedForm.minPrice !== undefined) params.minPrice = advancedForm.minPrice
|
if (advancedForm.minPrice !== undefined) params.minPrice = advancedForm.minPrice
|
||||||
if (advancedForm.maxPrice !== undefined) params.maxPrice = advancedForm.maxPrice
|
if (advancedForm.maxPrice !== undefined) params.maxPrice = advancedForm.maxPrice
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
path: '/products',
|
path: '/products',
|
||||||
query: params
|
query: params
|
||||||
@@ -354,11 +358,11 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.search-component {
|
.search-component {
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
@@ -376,35 +380,35 @@ onMounted(async () => {
|
|||||||
.search-panel {
|
.search-panel {
|
||||||
.search-section {
|
.search-section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-list {
|
.tag-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.el-tag {
|
.el-tag {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #efefed;
|
background-color: #efefed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-suggestions {
|
.search-suggestions {
|
||||||
.suggestion-list {
|
.suggestion-list {
|
||||||
.suggestion-item {
|
.suggestion-item {
|
||||||
@@ -414,32 +418,32 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f7f7f6;
|
background-color: #f7f7f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
|
|
||||||
.type {
|
.type {
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
background-color: #efefed;
|
background-color: #efefed;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price {
|
.price {
|
||||||
color: #2b2b27;
|
color: #2b2b27;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -449,12 +453,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced-search {
|
.advanced-search {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
border-top: 1px solid #d8cebf;
|
border-top: 1px solid #d8cebf;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
|
||||||
.advanced-form {
|
.advanced-form {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,78 +5,96 @@
|
|||||||
<el-aside :width="isCollapse ? '64px' : '200px'" class="admin-sidebar">
|
<el-aside :width="isCollapse ? '64px' : '200px'" class="admin-sidebar">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<el-icon :size="24" class="logo-icon">
|
<el-icon :size="24" class="logo-icon">
|
||||||
<Lightning />
|
<Lightning/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<span v-if="!isCollapse" class="logo-text">管理后台</span>
|
<span v-if="!isCollapse" class="logo-text">管理后台</span>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:collapse="isCollapse"
|
||||||
:collapse="isCollapse"
|
:collapse-transition="false"
|
||||||
:collapse-transition="false"
|
:default-active="activeMenu"
|
||||||
router
|
router
|
||||||
>
|
>
|
||||||
<el-menu-item index="/admin">
|
<el-menu-item index="/admin">
|
||||||
<el-icon><DataLine /></el-icon>
|
<el-icon>
|
||||||
|
<DataLine/>
|
||||||
|
</el-icon>
|
||||||
<template #title>仪表盘</template>
|
<template #title>仪表盘</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/products">
|
<el-menu-item index="/admin/products">
|
||||||
<el-icon><ShoppingBag /></el-icon>
|
<el-icon>
|
||||||
|
<ShoppingBag/>
|
||||||
|
</el-icon>
|
||||||
<template #title>商品管理</template>
|
<template #title>商品管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/groupbuying">
|
<el-menu-item index="/admin/groupbuying">
|
||||||
<el-icon><Connection /></el-icon>
|
<el-icon>
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
<template #title>拼团管理</template>
|
<template #title>拼团管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/orders">
|
<el-menu-item index="/admin/orders">
|
||||||
<el-icon><List /></el-icon>
|
<el-icon>
|
||||||
|
<List/>
|
||||||
|
</el-icon>
|
||||||
<template #title>订单管理</template>
|
<template #title>订单管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/users">
|
<el-menu-item index="/admin/users">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon>
|
||||||
|
<User/>
|
||||||
|
</el-icon>
|
||||||
<template #title>用户管理</template>
|
<template #title>用户管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/returns">
|
<el-menu-item index="/admin/returns">
|
||||||
<el-icon><RefreshLeft /></el-icon>
|
<el-icon>
|
||||||
|
<RefreshLeft/>
|
||||||
|
</el-icon>
|
||||||
<template #title>退货管理</template>
|
<template #title>退货管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/reviews">
|
<el-menu-item index="/admin/reviews">
|
||||||
<el-icon><ChatDotRound /></el-icon>
|
<el-icon>
|
||||||
|
<ChatDotRound/>
|
||||||
|
</el-icon>
|
||||||
<template #title>评价管理</template>
|
<template #title>评价管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/favorites">
|
<el-menu-item index="/admin/favorites">
|
||||||
<el-icon><Star /></el-icon>
|
<el-icon>
|
||||||
|
<Star/>
|
||||||
|
</el-icon>
|
||||||
<template #title>收藏管理</template>
|
<template #title>收藏管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item index="/admin/monitor">
|
<el-menu-item index="/admin/monitor">
|
||||||
<el-icon><Monitor /></el-icon>
|
<el-icon>
|
||||||
|
<Monitor/>
|
||||||
|
</el-icon>
|
||||||
<template #title>系统监控</template>
|
<template #title>系统监控</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-container>
|
<el-container>
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<el-header class="admin-header">
|
<el-header class="admin-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-icon
|
<el-icon
|
||||||
class="collapse-btn"
|
:size="20"
|
||||||
:size="20"
|
class="collapse-btn"
|
||||||
@click="isCollapse = !isCollapse"
|
@click="isCollapse = !isCollapse"
|
||||||
>
|
>
|
||||||
<component :is="isCollapse ? 'Expand' : 'Fold'" />
|
<component :is="isCollapse ? 'Expand' : 'Fold'"/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
|
|
||||||
<el-breadcrumb separator="/">
|
<el-breadcrumb separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/admin' }">
|
<el-breadcrumb-item :to="{ path: '/admin' }">
|
||||||
管理后台
|
管理后台
|
||||||
@@ -86,18 +104,18 @@
|
|||||||
</el-breadcrumb-item>
|
</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<!-- 全屏 -->
|
<!-- 全屏 -->
|
||||||
<el-icon class="header-icon" @click="toggleFullscreen">
|
<el-icon class="header-icon" @click="toggleFullscreen">
|
||||||
<FullScreen />
|
<FullScreen/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
|
|
||||||
<!-- 刷新 -->
|
<!-- 刷新 -->
|
||||||
<el-icon class="header-icon" @click="handleRefresh">
|
<el-icon class="header-icon" @click="handleRefresh">
|
||||||
<Refresh />
|
<Refresh/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
|
|
||||||
<!-- 用户信息 -->
|
<!-- 用户信息 -->
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
@@ -109,7 +127,9 @@
|
|||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="handleLogout">
|
<el-dropdown-item @click="handleLogout">
|
||||||
<el-icon><SwitchButton /></el-icon>
|
<el-icon>
|
||||||
|
<SwitchButton/>
|
||||||
|
</el-icon>
|
||||||
退出登录
|
退出登录
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
@@ -117,12 +137,12 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<el-main class="admin-main">
|
<el-main class="admin-main">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade-transform" mode="out-in">
|
<transition mode="out-in" name="fade-transform">
|
||||||
<component :is="Component" />
|
<component :is="Component"/>
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</el-main>
|
</el-main>
|
||||||
@@ -131,11 +151,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue'
|
import {ref, computed} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import {ElMessageBox} from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -181,16 +201,16 @@ const handleLogout = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
|
|
||||||
await userStore.logout()
|
await userStore.logout()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
.el-container {
|
.el-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -200,7 +220,7 @@ const handleLogout = async () => {
|
|||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
border-right: 1px solid #d8cebf;
|
border-right: 1px solid #d8cebf;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
|
|
||||||
.logo-container {
|
.logo-container {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -209,11 +229,11 @@ const handleLogout = async () => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #171715;
|
color: #171715;
|
||||||
border-bottom: 1px solid #d8cebf;
|
border-bottom: 1px solid #d8cebf;
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -221,21 +241,21 @@ const handleLogout = async () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
|
|
||||||
:deep(.el-menu-item) {
|
:deep(.el-menu-item) {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f4ede4 !important;
|
background-color: #f4ede4 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
background-color: #fffdf8 !important;
|
background-color: #fffdf8 !important;
|
||||||
@@ -253,37 +273,37 @@ const handleLogout = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
.collapse-btn {
|
.collapse-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #171715;
|
color: #171715;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -293,7 +313,7 @@ const handleLogout = async () => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #2b2b27;
|
color: #2b2b27;
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-layout">
|
<div class="main-layout">
|
||||||
<AppHeader />
|
<AppHeader/>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade-transform" mode="out-in">
|
<transition mode="out-in" name="fade-transform">
|
||||||
<component :is="Component" />
|
<component :is="Component"/>
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import AppHeader from '@/components/common/AppHeader.vue'
|
import AppHeader from '@/components/common/AppHeader.vue'
|
||||||
import AppFooter from '@/components/common/AppFooter.vue'
|
import AppFooter from '@/components/common/AppFooter.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.main-layout {
|
.main-layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createApp } from 'vue'
|
import {createApp} from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import {createPinia} from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
@@ -15,13 +15,13 @@ const app = createApp(App)
|
|||||||
|
|
||||||
// 注册所有图标
|
// 注册所有图标
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
app.component(key, component)
|
app.component(key, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus, {
|
app.use(ElementPlus, {
|
||||||
locale: zhCn,
|
locale: zhCn,
|
||||||
})
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon tone-1">
|
<div class="stat-icon tone-1">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon>
|
||||||
|
<User/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-value">{{ statistics.totalUsers }}</div>
|
<div class="stat-value">{{ statistics.totalUsers }}</div>
|
||||||
@@ -13,7 +15,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon tone-2">
|
<div class="stat-icon tone-2">
|
||||||
<el-icon><ShoppingBag /></el-icon>
|
<el-icon>
|
||||||
|
<ShoppingBag/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-value">{{ statistics.totalProducts }}</div>
|
<div class="stat-value">{{ statistics.totalProducts }}</div>
|
||||||
@@ -23,7 +27,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon tone-3">
|
<div class="stat-icon tone-3">
|
||||||
<el-icon><List /></el-icon>
|
<el-icon>
|
||||||
|
<List/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-value">{{ statistics.totalOrders }}</div>
|
<div class="stat-value">{{ statistics.totalOrders }}</div>
|
||||||
@@ -33,7 +39,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon tone-4">
|
<div class="stat-icon tone-4">
|
||||||
<el-icon><Coin /></el-icon>
|
<el-icon>
|
||||||
|
<Coin/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-value">¥{{ formatCurrency(statistics.totalAmount) }}</div>
|
<div class="stat-value">¥{{ formatCurrency(statistics.totalAmount) }}</div>
|
||||||
@@ -76,13 +84,13 @@
|
|||||||
<el-button text type="primary" @click="router.push('/admin/orders')">查看全部</el-button>
|
<el-button text type="primary" @click="router.push('/admin/orders')">查看全部</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="recentOrders" stripe>
|
<el-table v-loading="loading" :data="recentOrders" stripe>
|
||||||
<el-table-column prop="orderNo" label="订单号" min-width="120" />
|
<el-table-column label="订单号" min-width="120" prop="orderNo"/>
|
||||||
<el-table-column prop="username" label="用户" min-width="100" />
|
<el-table-column label="用户" min-width="100" prop="username"/>
|
||||||
<el-table-column prop="productName" label="商品" min-width="140" show-overflow-tooltip />
|
<el-table-column label="商品" min-width="140" prop="productName" show-overflow-tooltip/>
|
||||||
<el-table-column prop="totalAmount" label="金额" min-width="100">
|
<el-table-column label="金额" min-width="100" prop="totalAmount">
|
||||||
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
|
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" min-width="100">
|
<el-table-column label="状态" min-width="100" prop="status">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getOrderStatusType(row.status)">{{ getOrderStatusText(row.status) }}</el-tag>
|
<el-tag :type="getOrderStatusType(row.status)">{{ getOrderStatusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,12 +107,12 @@
|
|||||||
<el-button text type="primary" @click="router.push('/admin/products')">查看全部</el-button>
|
<el-button text type="primary" @click="router.push('/admin/products')">查看全部</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="hotProducts" stripe>
|
<el-table v-loading="loading" :data="hotProducts" stripe>
|
||||||
<el-table-column prop="name" label="商品名称" min-width="160" show-overflow-tooltip />
|
<el-table-column label="商品名称" min-width="160" prop="name" show-overflow-tooltip/>
|
||||||
<el-table-column prop="price" label="价格" min-width="100">
|
<el-table-column label="价格" min-width="100" prop="price">
|
||||||
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
|
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="sales" label="销量" min-width="80" />
|
<el-table-column label="销量" min-width="80" prop="sales"/>
|
||||||
<el-table-column prop="stock" label="库存" min-width="100">
|
<el-table-column label="库存" min-width="100" prop="stock">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
|
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -115,11 +123,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import {nextTick, onMounted, onUnmounted, reactive, ref} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import type {
|
import type {
|
||||||
AdminDashboardStats,
|
AdminDashboardStats,
|
||||||
AdminHotProductRow,
|
AdminHotProductRow,
|
||||||
@@ -195,16 +203,16 @@ const renderSalesChart = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
salesChart.setOption({
|
salesChart.setOption({
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: {trigger: 'axis'},
|
||||||
grid: { left: 24, right: 12, top: 24, bottom: 24, containLabel: true },
|
grid: {left: 24, right: 12, top: 24, bottom: 24, containLabel: true},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: recentOrders.value.map((item) => item.orderNo),
|
data: recentOrders.value.map((item) => item.orderNo),
|
||||||
axisLabel: { rotate: 30 },
|
axisLabel: {rotate: 30},
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: { formatter: '¥{value}' },
|
axisLabel: {formatter: '¥{value}'},
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -226,8 +234,8 @@ const renderCategoryChart = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
categoryChart.setOption({
|
categoryChart.setOption({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: {trigger: 'item'},
|
||||||
legend: { bottom: 0 },
|
legend: {bottom: 0},
|
||||||
color: ['#171715', '#5e5e58', '#9f9f99'],
|
color: ['#171715', '#5e5e58', '#9f9f99'],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -235,9 +243,9 @@ const renderCategoryChart = () => {
|
|||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['42%', '72%'],
|
radius: ['42%', '72%'],
|
||||||
data: [
|
data: [
|
||||||
{ value: productStats.activeProducts, name: '上架商品' },
|
{value: productStats.activeProducts, name: '上架商品'},
|
||||||
{ value: productStats.inactiveProducts, name: '下架商品' },
|
{value: productStats.inactiveProducts, name: '下架商品'},
|
||||||
{ value: productStats.lowStockProducts, name: '低库存商品' },
|
{value: productStats.lowStockProducts, name: '低库存商品'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -286,7 +294,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.admin-dashboard {
|
.admin-dashboard {
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@apply bg-white rounded-xl p-5 flex items-center gap-4;
|
@apply bg-white rounded-xl p-5 flex items-center gap-4;
|
||||||
184
community-fresh-group-buy-frontend/src/pages/admin/favorites.vue
Normal file
184
community-fresh-group-buy-frontend/src/pages/admin/favorites.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">收藏管理</h2>
|
||||||
|
<p class="page-subtitle">查看用户收藏关系并支持后台删除</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button @click="reloadData">
|
||||||
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="mini-stat blue">
|
||||||
|
<div class="mini-stat__value">{{ stats.totalFavorites }}</div>
|
||||||
|
<div class="mini-stat__label">收藏总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat green">
|
||||||
|
<div class="mini-stat__value">{{ stats.favoriteUsers }}</div>
|
||||||
|
<div class="mini-stat__label">收藏用户数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat orange">
|
||||||
|
<div class="mini-stat__value">{{ stats.favoriteProducts }}</div>
|
||||||
|
<div class="mini-stat__label">被收藏商品数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat purple">
|
||||||
|
<div class="mini-stat__value">{{ stats.todayFavorites }}</div>
|
||||||
|
<div class="mini-stat__label">今日新增收藏</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card filter-card">
|
||||||
|
<el-input v-model="keyword" clearable placeholder="搜索用户 / 商品" @keyup.enter="loadFavorites"/>
|
||||||
|
<el-button type="primary" @click="loadFavorites">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<el-table v-loading="loading" :data="favorites" stripe>
|
||||||
|
<el-table-column label="商品" min-width="180" prop="productName" show-overflow-tooltip/>
|
||||||
|
<el-table-column label="分类" prop="productCategory" width="120"/>
|
||||||
|
<el-table-column label="用户" prop="username" width="120"/>
|
||||||
|
<el-table-column label="收藏时间" min-width="170" prop="createdAt"/>
|
||||||
|
<el-table-column label="操作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="danger" @click="removeFavorite(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="table-footer">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="size" :page-sizes="[10,20,50]" :total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper" @current-change="loadFavorites"
|
||||||
|
@size-change="loadFavorites"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {onMounted, reactive, ref} from 'vue'
|
||||||
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
|
import {adminApi} from '@/api/modules/admin'
|
||||||
|
import type {AdminFavoriteRow, AdminFavoriteStats} from '@/types/admin'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const size = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const favorites = ref<AdminFavoriteRow[]>([])
|
||||||
|
const stats = reactive<AdminFavoriteStats>({
|
||||||
|
totalFavorites: 0,
|
||||||
|
favoriteUsers: 0,
|
||||||
|
favoriteProducts: 0,
|
||||||
|
todayFavorites: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
const res = await adminApi.getFavoriteStats()
|
||||||
|
Object.assign(stats, res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.getFavorites({page: page.value, size: size.value, keyword: keyword.value || undefined})
|
||||||
|
favorites.value = res.data.favorites
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFavorite = async (id: number) => {
|
||||||
|
await ElMessageBox.confirm('确定删除该收藏记录吗?', '提示', {type: 'warning'})
|
||||||
|
await adminApi.deleteFavorite(id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadStats();
|
||||||
|
loadFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrateItems = async () => {
|
||||||
|
const res = await adminApi.migrateLegacyOrderItems()
|
||||||
|
ElMessage.success(`迁移完成:迁移 ${res.data.migrated} 条,跳过 ${res.data.skipped} 条`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadData = async () => {
|
||||||
|
await Promise.all([loadStats(), loadFavorites()])
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
reloadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
@apply text-2xl font-bold text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
@apply text-sm text-slate-500 mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
|
color: #171715;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat__value {
|
||||||
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card {
|
||||||
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:1fr 100px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer {
|
||||||
|
@apply flex justify-end mt-4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,11 +7,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button @click="reloadData">
|
<el-button @click="reloadData">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" @click="openCreateDialog">
|
<el-button type="primary" @click="openCreateDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon>
|
||||||
|
<Plus/>
|
||||||
|
</el-icon>
|
||||||
创建限时
|
创建限时
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,13 +42,17 @@
|
|||||||
|
|
||||||
<div class="panel-card filter-card">
|
<div class="panel-card filter-card">
|
||||||
<el-input v-model="query.keyword" clearable placeholder="搜索商品名称" @keyup.enter="loadFlashSales">
|
<el-input v-model="query.keyword" clearable placeholder="搜索商品名称" @keyup.enter="loadFlashSales">
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Search/>
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||||
<el-option label="即将开始" value="UPCOMING" />
|
<el-option label="即将开始" value="UPCOMING"/>
|
||||||
<el-option label="进行中" value="ACTIVE" />
|
<el-option label="进行中" value="ACTIVE"/>
|
||||||
<el-option label="已暂停" value="PAUSED" />
|
<el-option label="已暂停" value="PAUSED"/>
|
||||||
<el-option label="已结束" value="ENDED" />
|
<el-option label="已结束" value="ENDED"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -52,11 +60,12 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<el-table v-loading="loading" :data="displayFlashSales" stripe>
|
<el-table v-loading="loading" :data="displayFlashSales" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column label="ID" prop="id" width="80"/>
|
||||||
<el-table-column label="商品" min-width="240">
|
<el-table-column label="商品" min-width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="product-cell">
|
<div class="product-cell">
|
||||||
<SafeImage :src="row.productImageUrl" :alt="row.productName" wrapper-class="product-image" img-class="product-image" />
|
<SafeImage :alt="row.productName" :src="row.productImageUrl" img-class="product-image"
|
||||||
|
wrapper-class="product-image"/>
|
||||||
<div>
|
<div>
|
||||||
<div class="product-name">{{ row.productName }}</div>
|
<div class="product-name">{{ row.productName }}</div>
|
||||||
<div class="product-meta">商品ID:{{ row.productId }}</div>
|
<div class="product-meta">商品ID:{{ row.productId }}</div>
|
||||||
@@ -64,33 +73,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="originalPrice" label="原价" width="110">
|
<el-table-column label="原价" prop="originalPrice" width="110">
|
||||||
<template #default="{ row }">¥{{ formatCurrency(row.originalPrice) }}</template>
|
<template #default="{ row }">¥{{ formatCurrency(row.originalPrice) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="活动价" prop="flashPrice" width="110">
|
<el-table-column label="活动价" prop="flashPrice" width="110">
|
||||||
<template #default="{ row }">¥{{ formatCurrency(row.flashPrice) }}</template>
|
<template #default="{ row }">¥{{ formatCurrency(row.flashPrice) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="flashStock" label="总库存" width="100" />
|
<el-table-column label="总库存" prop="flashStock" width="100"/>
|
||||||
<el-table-column prop="remainingStock" label="剩余库存" width="100" />
|
<el-table-column label="剩余库存" prop="remainingStock" width="100"/>
|
||||||
<el-table-column label="时间范围" min-width="220">
|
<el-table-column label="时间范围" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div>{{ formatTime(row.startTime) }}</div>
|
<div>{{ formatTime(row.startTime) }}</div>
|
||||||
<div class="text-slate-400">至 {{ formatTime(row.endTime) }}</div>
|
<div class="text-slate-400">至 {{ formatTime(row.endTime) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="110">
|
<el-table-column label="状态" prop="status" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="320" fixed="right">
|
<el-table-column fixed="right" label="操作" width="320">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button text type="primary" @click="openDetail(row)">查看</el-button>
|
<el-button text type="primary" @click="openDetail(row)">查看</el-button>
|
||||||
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
|
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
<el-button v-if="row.status === 'UPCOMING'" text type="success" @click="changeStatus('publish', row)">发布</el-button>
|
<el-button v-if="row.status === 'UPCOMING'" text type="success" @click="changeStatus('publish', row)">发布
|
||||||
<el-button v-if="row.status === 'ACTIVE'" text type="warning" @click="changeStatus('pause', row)">暂停</el-button>
|
</el-button>
|
||||||
<el-button v-if="row.status === 'PAUSED'" text type="success" @click="changeStatus('resume', row)">恢复</el-button>
|
<el-button v-if="row.status === 'ACTIVE'" text type="warning" @click="changeStatus('pause', row)">暂停
|
||||||
<el-button v-if="row.status === 'ACTIVE' || row.status === 'PAUSED'" text type="danger" @click="changeStatus('end', row)">结束</el-button>
|
</el-button>
|
||||||
|
<el-button v-if="row.status === 'PAUSED'" text type="success" @click="changeStatus('resume', row)">恢复
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="row.status === 'ACTIVE' || row.status === 'PAUSED'" text type="danger"
|
||||||
|
@click="changeStatus('end', row)">结束
|
||||||
|
</el-button>
|
||||||
<el-button text type="danger" @click="removeFlashSale(row)">删除</el-button>
|
<el-button text type="danger" @click="removeFlashSale(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -98,13 +112,13 @@
|
|||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[10, 20, 50]"
|
||||||
:page-sizes="[10, 20, 50]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@current-change="loadFlashSales"
|
@current-change="loadFlashSales"
|
||||||
@size-change="handlePageSizeChange"
|
@size-change="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,50 +126,51 @@
|
|||||||
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '创建限时活动' : '编辑限时活动'" width="760px">
|
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '创建限时活动' : '编辑限时活动'" width="760px">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
<el-form-item label="关联商品" prop="productId">
|
<el-form-item label="关联商品" prop="productId">
|
||||||
<el-select v-model="form.productId" filterable :disabled="formMode === 'edit'" placeholder="请选择商品">
|
<el-select v-model="form.productId" :disabled="formMode === 'edit'" filterable placeholder="请选择商品">
|
||||||
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="活动价格" prop="flashPrice">
|
<el-form-item label="活动价格" prop="flashPrice">
|
||||||
<el-input-number v-model="form.flashPrice" :min="0.01" :precision="2" class="w-full" />
|
<el-input-number v-model="form.flashPrice" :min="0.01" :precision="2" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="活动库存" prop="flashStock">
|
<el-form-item label="活动库存" prop="flashStock">
|
||||||
<el-input-number v-model="form.flashStock" :min="1" class="w-full" />
|
<el-input-number v-model="form.flashStock" :min="1" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="开始时间" prop="startTime">
|
<el-form-item label="开始时间" prop="startTime">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.startTime"
|
v-model="form.startTime"
|
||||||
type="datetime"
|
:disabled-date="disablePastDate"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
class="w-full"
|
||||||
:disabled-date="disablePastDate"
|
type="datetime"
|
||||||
class="w-full"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="结束时间" prop="endTime">
|
<el-form-item label="结束时间" prop="endTime">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.endTime"
|
v-model="form.endTime"
|
||||||
type="datetime"
|
:disabled-date="disablePastDate"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
class="w-full"
|
||||||
:disabled-date="disablePastDate"
|
type="datetime"
|
||||||
class="w-full"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="formVisible = false">取消</el-button>
|
<el-button @click="formVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
<el-button :loading="saving" type="primary" @click="submitForm">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" title="限时详情" width="760px">
|
<el-dialog v-model="detailVisible" title="限时详情" width="760px">
|
||||||
<div v-if="currentItem" class="detail-layout">
|
<div v-if="currentItem" class="detail-layout">
|
||||||
<SafeImage :src="currentItem.productImageUrl" :alt="currentItem.productName" wrapper-class="detail-image" img-class="detail-image" />
|
<SafeImage :alt="currentItem.productName" :src="currentItem.productImageUrl" img-class="detail-image"
|
||||||
|
wrapper-class="detail-image"/>
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<h3>{{ currentItem.productName }}</h3>
|
<h3>{{ currentItem.productName }}</h3>
|
||||||
<div class="price-line">
|
<div class="price-line">
|
||||||
@@ -170,7 +185,7 @@
|
|||||||
<div><span>开始时间:</span>{{ formatTime(currentItem.startTime) }}</div>
|
<div><span>开始时间:</span>{{ formatTime(currentItem.startTime) }}</div>
|
||||||
<div><span>结束时间:</span>{{ formatTime(currentItem.endTime) }}</div>
|
<div><span>结束时间:</span>{{ formatTime(currentItem.endTime) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<el-progress :percentage="getStockRate(currentItem)" :stroke-width="10" class="mt-5" />
|
<el-progress :percentage="getStockRate(currentItem)" :stroke-width="10" class="mt-5"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -180,16 +195,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import {computed, onMounted, reactive, ref} from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type {FormInstance, FormRules} from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
import {flashsaleApi} from '@/api/modules/flashsale'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import type { FlashSale } from '@/types/api'
|
import type {FlashSale} from '@/types/api'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import type { AdminFlashSaleStats, AdminProductRow } from '@/types/admin'
|
import type {AdminFlashSaleStats, AdminProductRow} from '@/types/admin'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -235,11 +250,11 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
productId: [{ required: true, message: '请选择商品', trigger: 'change' }],
|
productId: [{required: true, message: '请选择商品', trigger: 'change'}],
|
||||||
flashPrice: [{required: true, message: '请输入活动价格', trigger: 'change'}],
|
flashPrice: [{required: true, message: '请输入活动价格', trigger: 'change'}],
|
||||||
flashStock: [{required: true, message: '请输入活动库存', trigger: 'change'}],
|
flashStock: [{required: true, message: '请输入活动库存', trigger: 'change'}],
|
||||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
startTime: [{required: true, message: '请选择开始时间', trigger: 'change'}],
|
||||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
endTime: [{required: true, message: '请选择结束时间', trigger: 'change'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayFlashSales = computed(() => {
|
const displayFlashSales = computed(() => {
|
||||||
@@ -317,7 +332,7 @@ const loadStats = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadProducts = async () => {
|
const loadProducts = async () => {
|
||||||
const res = await adminApi.getProducts({ page: 1, size: 100 })
|
const res = await adminApi.getProducts({page: 1, size: 100})
|
||||||
productOptions.value = res.data.products.filter((item) => item.status === 1)
|
productOptions.value = res.data.products.filter((item) => item.status === 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +465,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -490,8 +505,13 @@ onMounted(() => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value {
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@@ -7,11 +7,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button @click="reloadData">
|
<el-button @click="reloadData">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" @click="openCreateDialog">
|
<el-button type="primary" @click="openCreateDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon>
|
||||||
|
<Plus/>
|
||||||
|
</el-icon>
|
||||||
创建拼团
|
创建拼团
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,10 +42,10 @@
|
|||||||
|
|
||||||
<div class="panel-card filter-card">
|
<div class="panel-card filter-card">
|
||||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||||
<el-option label="草稿" value="DRAFT" />
|
<el-option label="草稿" value="DRAFT"/>
|
||||||
<el-option label="即将开始" value="UPCOMING" />
|
<el-option label="即将开始" value="UPCOMING"/>
|
||||||
<el-option label="进行中" value="ACTIVE" />
|
<el-option label="进行中" value="ACTIVE"/>
|
||||||
<el-option label="已结束" value="ENDED" />
|
<el-option label="已结束" value="ENDED"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -49,11 +53,12 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<el-table v-loading="loading" :data="list" stripe>
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column label="ID" prop="id" width="80"/>
|
||||||
<el-table-column label="商品" min-width="240">
|
<el-table-column label="商品" min-width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="product-cell">
|
<div class="product-cell">
|
||||||
<SafeImage :src="row.productImageUrl" :alt="row.productName" wrapper-class="product-image" img-class="product-image" />
|
<SafeImage :alt="row.productName" :src="row.productImageUrl" img-class="product-image"
|
||||||
|
wrapper-class="product-image"/>
|
||||||
<div>
|
<div>
|
||||||
<div class="product-name">{{ row.productName }}</div>
|
<div class="product-name">{{ row.productName }}</div>
|
||||||
<div class="product-meta">商品ID:{{ row.productId }}</div>
|
<div class="product-meta">商品ID:{{ row.productId }}</div>
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
<el-table-column label="拼团价" width="100">
|
<el-table-column label="拼团价" width="100">
|
||||||
<template #default="{ row }"><span class="font-bold">¥{{ formatCurrency(row.groupPrice) }}</span></template>
|
<template #default="{ row }"><span class="font-bold">¥{{ formatCurrency(row.groupPrice) }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="requiredMembers" label="成团人数" width="90" />
|
<el-table-column label="成团人数" prop="requiredMembers" width="90"/>
|
||||||
<el-table-column label="库存" width="120">
|
<el-table-column label="库存" width="120">
|
||||||
<template #default="{ row }">{{ row.remainingStock }} / {{ row.totalStock }}</template>
|
<template #default="{ row }">{{ row.remainingStock }} / {{ row.totalStock }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -82,7 +87,7 @@
|
|||||||
<el-tag :type="getStatusType(row.status)">{{ row.statusDescription }}</el-tag>
|
<el-tag :type="getStatusType(row.status)">{{ row.statusDescription }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="240" fixed="right">
|
<el-table-column fixed="right" label="操作" width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
|
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
<el-button v-if="row.status === 'DRAFT'" text type="success" @click="publishActivity(row)">发布</el-button>
|
<el-button v-if="row.status === 'DRAFT'" text type="success" @click="publishActivity(row)">发布</el-button>
|
||||||
@@ -98,13 +103,13 @@
|
|||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[10, 20, 50]"
|
||||||
:page-sizes="[10, 20, 50]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
@current-change="loadList"
|
@current-change="loadList"
|
||||||
@size-change="handlePageSizeChange"
|
@size-change="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,73 +118,74 @@
|
|||||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑拼团活动' : '创建拼团活动'" width="760px">
|
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑拼团活动' : '创建拼团活动'" width="760px">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
<el-form-item label="关联商品" prop="productId">
|
<el-form-item label="关联商品" prop="productId">
|
||||||
<el-select v-model="form.productId" filterable :disabled="!!editingId" placeholder="请选择商品" class="w-full">
|
<el-select v-model="form.productId" :disabled="!!editingId" class="w-full" filterable
|
||||||
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
placeholder="请选择商品">
|
||||||
|
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="拼团价格" prop="groupPrice">
|
<el-form-item label="拼团价格" prop="groupPrice">
|
||||||
<el-input-number v-model="form.groupPrice" :min="0.01" :precision="2" class="w-full" />
|
<el-input-number v-model="form.groupPrice" :min="0.01" :precision="2" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="总库存" prop="totalStock">
|
<el-form-item label="总库存" prop="totalStock">
|
||||||
<el-input-number v-model="form.totalStock" :min="1" class="w-full" />
|
<el-input-number v-model="form.totalStock" :min="1" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="成团人数" prop="requiredMembers">
|
<el-form-item label="成团人数" prop="requiredMembers">
|
||||||
<el-input-number v-model="form.requiredMembers" :min="2" :max="100" class="w-full" />
|
<el-input-number v-model="form.requiredMembers" :max="100" :min="2" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="每人限购" prop="maxPerUser">
|
<el-form-item label="每人限购" prop="maxPerUser">
|
||||||
<el-input-number v-model="form.maxPerUser" :min="1" :max="10" class="w-full" />
|
<el-input-number v-model="form.maxPerUser" :max="10" :min="1" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="有效期(分钟)">
|
<el-form-item label="有效期(分钟)">
|
||||||
<el-input-number v-model="form.durationMinutes" :min="1" :max="10080" class="w-full" />
|
<el-input-number v-model="form.durationMinutes" :max="10080" :min="1" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="开始时间" prop="startTime">
|
<el-form-item label="开始时间" prop="startTime">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.startTime"
|
v-model="form.startTime"
|
||||||
type="datetime"
|
:disabled-date="disablePastDate"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
class="w-full"
|
||||||
:disabled-date="disablePastDate"
|
type="datetime"
|
||||||
class="w-full"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="结束时间" prop="endTime">
|
<el-form-item label="结束时间" prop="endTime">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.endTime"
|
v-model="form.endTime"
|
||||||
type="datetime"
|
:disabled-date="disablePastDate"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
class="w-full"
|
||||||
:disabled-date="disablePastDate"
|
type="datetime"
|
||||||
class="w-full"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
<el-button :loading="submitting" type="primary" @click="handleSubmit">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import {ref, reactive, onMounted} from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type {FormInstance, FormRules} from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { GroupBuying, GroupBuyingStatistics } from '@/types/api'
|
import type {GroupBuying, GroupBuyingStatistics} from '@/types/api'
|
||||||
import type { AdminProductRow } from '@/types/admin'
|
import type {AdminProductRow} from '@/types/admin'
|
||||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
import {groupbuyingApi} from '@/api/modules/groupbuying'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -200,9 +206,9 @@ const stats = ref<GroupBuyingStatistics>({
|
|||||||
totalSaved: 0,
|
totalSaved: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const query = reactive({ status: '' as string })
|
const query = reactive({status: '' as string})
|
||||||
|
|
||||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
const pagination = reactive({page: 1, size: 10, total: 0})
|
||||||
|
|
||||||
const buildDefaultStartTime = () => dayjs().add(5, 'minute').startOf('minute').format(TIME_FORMAT)
|
const buildDefaultStartTime = () => dayjs().add(5, 'minute').startOf('minute').format(TIME_FORMAT)
|
||||||
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(1, 'day').format(TIME_FORMAT)
|
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(1, 'day').format(TIME_FORMAT)
|
||||||
@@ -219,12 +225,12 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
productId: [{ required: true, message: '请选择商品', trigger: 'change' }],
|
productId: [{required: true, message: '请选择商品', trigger: 'change'}],
|
||||||
groupPrice: [{ required: true, message: '请输入拼团价格', trigger: 'change' }],
|
groupPrice: [{required: true, message: '请输入拼团价格', trigger: 'change'}],
|
||||||
totalStock: [{ required: true, message: '请输入总库存', trigger: 'change' }],
|
totalStock: [{required: true, message: '请输入总库存', trigger: 'change'}],
|
||||||
requiredMembers: [{ required: true, message: '请输入成团人数', trigger: 'change' }],
|
requiredMembers: [{required: true, message: '请输入成团人数', trigger: 'change'}],
|
||||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
startTime: [{required: true, message: '请选择开始时间', trigger: 'change'}],
|
||||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
endTime: [{required: true, message: '请选择结束时间', trigger: 'change'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
||||||
@@ -232,11 +238,16 @@ const formatTime = (value: string) => dayjs(value).format(TIME_FORMAT)
|
|||||||
|
|
||||||
const getStatusType = (status: string) => {
|
const getStatusType = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'DRAFT': return 'info'
|
case 'DRAFT':
|
||||||
case 'UPCOMING': return 'warning'
|
return 'info'
|
||||||
case 'ACTIVE': return 'success'
|
case 'UPCOMING':
|
||||||
case 'ENDED': return ''
|
return 'warning'
|
||||||
default: return 'info'
|
case 'ACTIVE':
|
||||||
|
return 'success'
|
||||||
|
case 'ENDED':
|
||||||
|
return ''
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +291,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadProducts = async () => {
|
const loadProducts = async () => {
|
||||||
const res = await adminApi.getProducts({ page: 1, size: 100 })
|
const res = await adminApi.getProducts({page: 1, size: 100})
|
||||||
productOptions.value = res.data.products.filter((item) => item.status === 1)
|
productOptions.value = res.data.products.filter((item) => item.status === 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +344,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const payload = { ...form, productId: form.productId! }
|
const payload = {...form, productId: form.productId!}
|
||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await groupbuyingApi.update(editingId.value, payload)
|
await groupbuyingApi.update(editingId.value, payload)
|
||||||
@@ -353,9 +364,9 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const publishActivity = async (row: GroupBuying) => {
|
const publishActivity = async (row: GroupBuying) => {
|
||||||
await ElMessageBox.confirm(`确定要发布活动吗?`, '发布确认', { type: 'warning' })
|
await ElMessageBox.confirm(`确定要发布活动吗?`, '发布确认', {type: 'warning'})
|
||||||
try {
|
try {
|
||||||
await groupbuyingApi.update(row.id, { status: 1 })
|
await groupbuyingApi.update(row.id, {status: 1})
|
||||||
ElMessage.success('已发布')
|
ElMessage.success('已发布')
|
||||||
await reloadData()
|
await reloadData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -364,7 +375,7 @@ const publishActivity = async (row: GroupBuying) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeActivity = async (row: GroupBuying) => {
|
const removeActivity = async (row: GroupBuying) => {
|
||||||
await ElMessageBox.confirm('确定要删除该拼团活动吗?', '删除确认', { type: 'warning' })
|
await ElMessageBox.confirm('确定要删除该拼团活动吗?', '删除确认', {type: 'warning'})
|
||||||
try {
|
try {
|
||||||
await groupbuyingApi.delete(row.id)
|
await groupbuyingApi.delete(row.id)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
@@ -398,7 +409,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -438,8 +449,13 @@ onMounted(() => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value {
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
<p class="page-subtitle">复刻 JSP 监控页的指标卡、服务状态、性能趋势与实时日志</p>
|
<p class="page-subtitle">复刻 JSP 监控页的指标卡、服务状态、性能趋势与实时日志</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" @change="toggleAutoRefresh" />
|
<el-switch v-model="autoRefresh" active-text="自动刷新" inactive-text="手动" inline-prompt
|
||||||
|
@change="toggleAutoRefresh"/>
|
||||||
<el-button @click="clearLogs">清空日志</el-button>
|
<el-button @click="clearLogs">清空日志</el-button>
|
||||||
<el-button type="primary" @click="refreshAll">
|
<el-button type="primary" @click="refreshAll">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新全部
|
刷新全部
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,11 +52,13 @@
|
|||||||
<el-tag type="success">运行中</el-tag>
|
<el-tag type="success">运行中</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="service-row">
|
<div class="service-row">
|
||||||
<div class="service-name"><span :class="['dot', redisHealthy ? 'success' : 'danger']"></span>Redis 集群</div>
|
<div class="service-name"><span :class="['dot', redisHealthy ? 'success' : 'danger']"></span>Redis 集群
|
||||||
|
</div>
|
||||||
<el-tag :type="redisHealthy ? 'success' : 'danger'">{{ redisHealthy ? '正常' : '异常' }}</el-tag>
|
<el-tag :type="redisHealthy ? 'success' : 'danger'">{{ redisHealthy ? '正常' : '异常' }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="service-row">
|
<div class="service-row">
|
||||||
<div class="service-name"><span :class="['dot', mysqlHealthy ? 'success' : 'danger']"></span>MySQL 服务</div>
|
<div class="service-name"><span :class="['dot', mysqlHealthy ? 'success' : 'danger']"></span>MySQL 服务
|
||||||
|
</div>
|
||||||
<el-tag :type="mysqlHealthy ? 'success' : 'danger'">{{ mysqlHealthy ? '正常' : '异常' }}</el-tag>
|
<el-tag :type="mysqlHealthy ? 'success' : 'danger'">{{ mysqlHealthy ? '正常' : '异常' }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,14 +84,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="redisNodes" stripe>
|
<el-table v-loading="loading" :data="redisNodes" stripe>
|
||||||
<el-table-column prop="node" label="节点" min-width="180" />
|
<el-table-column label="节点" min-width="180" prop="node"/>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column label="状态" prop="status" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === '正常' ? 'success' : 'danger'">{{ row.status }}</el-tag>
|
<el-tag :type="row.status === '正常' ? 'success' : 'danger'">{{ row.status }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="memory" label="内存" width="110" />
|
<el-table-column label="内存" prop="memory" width="110"/>
|
||||||
<el-table-column prop="connections" label="连接数" width="110" />
|
<el-table-column label="连接数" prop="connections" width="110"/>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,11 +123,11 @@
|
|||||||
|
|
||||||
<div class="alerts-box">
|
<div class="alerts-box">
|
||||||
<el-alert
|
<el-alert
|
||||||
:title="systemAlert.title"
|
:closable="false"
|
||||||
:description="systemAlert.description"
|
:description="systemAlert.description"
|
||||||
:type="systemAlert.type"
|
:title="systemAlert.title"
|
||||||
show-icon
|
:type="systemAlert.type"
|
||||||
:closable="false"
|
show-icon
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +143,10 @@
|
|||||||
<div class="log-list">
|
<div class="log-list">
|
||||||
<div v-for="item in logs" :key="item.id" class="log-row">
|
<div v-for="item in logs" :key="item.id" class="log-row">
|
||||||
<span class="log-time">{{ item.time }}</span>
|
<span class="log-time">{{ item.time }}</span>
|
||||||
<el-tag size="small" :type="item.level === 'error' ? 'danger' : item.level === 'warn' ? 'warning' : 'success'">{{ item.level.toUpperCase() }}</el-tag>
|
<el-tag :type="item.level === 'error' ? 'danger' : item.level === 'warn' ? 'warning' : 'success'"
|
||||||
|
size="small">
|
||||||
|
{{ item.level.toUpperCase() }}
|
||||||
|
</el-tag>
|
||||||
<span class="log-message">{{ item.message }}</span>
|
<span class="log-message">{{ item.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,12 +154,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import {computed, nextTick, onMounted, onUnmounted, reactive, ref} from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import type { AdminFlashSaleStats, AdminUserStats, MonitorSystemStatus, RedisNodeStatus } from '@/types/admin'
|
import type {AdminFlashSaleStats, AdminUserStats, MonitorSystemStatus, RedisNodeStatus} from '@/types/admin'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: number
|
id: number
|
||||||
@@ -232,16 +240,16 @@ const renderChart = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: {trigger: 'axis'},
|
||||||
color: ['#171715', '#5e5e58', '#9f9f99'],
|
color: ['#171715', '#5e5e58', '#9f9f99'],
|
||||||
legend: { top: 0 },
|
legend: {top: 0},
|
||||||
grid: { left: 24, right: 24, top: 40, bottom: 24, containLabel: true },
|
grid: {left: 24, right: 24, top: 40, bottom: 24, containLabel: true},
|
||||||
xAxis: { type: 'category', data: history.time },
|
xAxis: {type: 'category', data: history.time},
|
||||||
yAxis: { type: 'value', max: 100 },
|
yAxis: {type: 'value', max: 100},
|
||||||
series: [
|
series: [
|
||||||
{ name: 'CPU', type: 'line', smooth: true, data: history.cpu },
|
{name: 'CPU', type: 'line', smooth: true, data: history.cpu},
|
||||||
{ name: '内存', type: 'line', smooth: true, data: history.memory },
|
{name: '内存', type: 'line', smooth: true, data: history.memory},
|
||||||
{ name: '磁盘', type: 'line', smooth: true, data: history.disk },
|
{name: '磁盘', type: 'line', smooth: true, data: history.disk},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -335,7 +343,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -376,8 +384,13 @@ onUnmounted(() => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value {
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-grid {
|
.content-grid {
|
||||||
@@ -434,8 +447,13 @@ onUnmounted(() => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot.success { background: #171715; }
|
.dot.success {
|
||||||
.dot.danger { background: #666666; }
|
background: #171715;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.danger {
|
||||||
|
background: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
height: 320px;
|
height: 320px;
|
||||||
@@ -7,12 +7,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button @click="reloadData">
|
<el-button @click="reloadData">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
|
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
|
||||||
<el-button @click="exportCsv">
|
<el-button @click="exportCsv">
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon>
|
||||||
|
<Download/>
|
||||||
|
</el-icon>
|
||||||
导出
|
导出
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,16 +43,20 @@
|
|||||||
|
|
||||||
<div class="panel-card filter-card">
|
<div class="panel-card filter-card">
|
||||||
<el-input v-model="query.keyword" clearable placeholder="搜索订单号 / 用户 / 商品" @keyup.enter="loadOrders">
|
<el-input v-model="query.keyword" clearable placeholder="搜索订单号 / 用户 / 商品" @keyup.enter="loadOrders">
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Search/>
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||||
<el-option label="待支付" value="1" />
|
<el-option label="待支付" value="1"/>
|
||||||
<el-option label="已支付" value="2" />
|
<el-option label="已支付" value="2"/>
|
||||||
<el-option label="已发货" value="3" />
|
<el-option label="已发货" value="3"/>
|
||||||
<el-option label="已完成" value="4" />
|
<el-option label="已完成" value="4"/>
|
||||||
<el-option label="已取消" value="5" />
|
<el-option label="已取消" value="5"/>
|
||||||
<el-option label="退货中" value="6" />
|
<el-option label="退货中" value="6"/>
|
||||||
<el-option label="已退货" value="7" />
|
<el-option label="已退货" value="7"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -56,11 +64,11 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<el-table v-loading="loading" :data="displayOrders" stripe>
|
<el-table v-loading="loading" :data="displayOrders" stripe>
|
||||||
<el-table-column prop="orderNo" label="订单号" min-width="120" />
|
<el-table-column label="订单号" min-width="120" prop="orderNo"/>
|
||||||
<el-table-column prop="username" label="用户" min-width="100" />
|
<el-table-column label="用户" min-width="100" prop="username"/>
|
||||||
<el-table-column prop="productName" label="商品" min-width="160" show-overflow-tooltip />
|
<el-table-column label="商品" min-width="160" prop="productName" show-overflow-tooltip/>
|
||||||
<el-table-column prop="quantity" label="数量" width="80" />
|
<el-table-column label="数量" prop="quantity" width="80"/>
|
||||||
<el-table-column prop="totalAmount" label="金额" width="110">
|
<el-table-column label="金额" prop="totalAmount" width="110">
|
||||||
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
|
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="类型" width="100">
|
<el-table-column label="类型" width="100">
|
||||||
@@ -68,32 +76,34 @@
|
|||||||
<el-tag :type="getOrderTypeTag(row.orderType)">{{ getOrderTypeText(row.orderType) }}</el-tag>
|
<el-tag :type="getOrderTypeTag(row.orderType)">{{ getOrderTypeText(row.orderType) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column label="状态" prop="status" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
<el-table-column label="创建时间" min-width="170" prop="createdAt">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="220" fixed="right">
|
<el-table-column fixed="right" label="操作" width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button text type="primary" @click="openDetail(row.id)">详情</el-button>
|
<el-button text type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||||
<el-button v-if="row.status === 'PAID'" text type="success" @click="shipOrder(row)">发货</el-button>
|
<el-button v-if="row.status === 'PAID'" text type="success" @click="shipOrder(row)">发货</el-button>
|
||||||
<el-button v-if="row.status === 'PENDING' || row.status === 'PAID'" text type="danger" @click="cancelOrder(row)">取消</el-button>
|
<el-button v-if="row.status === 'PENDING' || row.status === 'PAID'" text type="danger"
|
||||||
|
@click="cancelOrder(row)">取消
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[10, 20, 50]"
|
||||||
:page-sizes="[10, 20, 50]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@current-change="loadOrders"
|
@current-change="loadOrders"
|
||||||
@size-change="handlePageSizeChange"
|
@size-change="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,8 +118,9 @@
|
|||||||
<el-tag :type="getStatusType(currentOrder.status)">{{ getStatusText(currentOrder.status) }}</el-tag>
|
<el-tag :type="getStatusType(currentOrder.status)">{{ getStatusText(currentOrder.status) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="item-card" v-for="item in currentOrder.items" :key="item.id">
|
<div v-for="item in currentOrder.items" :key="item.id" class="item-card">
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="item-image" img-class="item-image" />
|
<SafeImage :alt="item.productName" :src="item.productImage" img-class="item-image"
|
||||||
|
wrapper-class="item-image"/>
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-name">{{ item.productName }}</div>
|
<div class="item-name">{{ item.productName }}</div>
|
||||||
<div class="item-meta">¥{{ formatCurrency(item.price) }} × {{ item.quantity }}</div>
|
<div class="item-meta">¥{{ formatCurrency(item.price) }} × {{ item.quantity }}</div>
|
||||||
@@ -133,15 +144,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import {computed, onMounted, reactive, ref} from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import { orderApi } from '@/api/modules/order'
|
import {orderApi} from '@/api/modules/order'
|
||||||
import type { Order } from '@/types/api'
|
import type {Order} from '@/types/api'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import type { AdminOrderRow, AdminOrderStats } from '@/types/admin'
|
import type {AdminOrderRow, AdminOrderStats} from '@/types/admin'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
@@ -209,7 +220,7 @@ const getOrderTypeText = (orderType: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOrderTypeTag = (orderType: string) => {
|
const getOrderTypeTag = (orderType: string) => {
|
||||||
const map: Record<string, string> = { NORMAL: 'info', FLASH_SALE: 'danger', GROUP_BUYING: 'success' }
|
const map: Record<string, string> = {NORMAL: 'info', FLASH_SALE: 'danger', GROUP_BUYING: 'success'}
|
||||||
return map[orderType] || 'info'
|
return map[orderType] || 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,14 +252,14 @@ const openDetail = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shipOrder = async (row: AdminOrderRow) => {
|
const shipOrder = async (row: AdminOrderRow) => {
|
||||||
await ElMessageBox.confirm(`确定将订单 ${row.orderNo} 标记为已发货吗?`, '发货确认', { type: 'warning' })
|
await ElMessageBox.confirm(`确定将订单 ${row.orderNo} 标记为已发货吗?`, '发货确认', {type: 'warning'})
|
||||||
await orderApi.ship(row.id)
|
await orderApi.ship(row.id)
|
||||||
ElMessage.success('订单已发货')
|
ElMessage.success('订单已发货')
|
||||||
await reloadData()
|
await reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelOrder = async (row: AdminOrderRow) => {
|
const cancelOrder = async (row: AdminOrderRow) => {
|
||||||
await ElMessageBox.confirm(`确定取消订单 ${row.orderNo} 吗?`, '取消确认', { type: 'warning' })
|
await ElMessageBox.confirm(`确定取消订单 ${row.orderNo} 吗?`, '取消确认', {type: 'warning'})
|
||||||
await orderApi.updateStatus(row.id, 5, '管理后台取消订单')
|
await orderApi.updateStatus(row.id, 5, '管理后台取消订单')
|
||||||
ElMessage.success('订单已取消')
|
ElMessage.success('订单已取消')
|
||||||
await reloadData()
|
await reloadData()
|
||||||
@@ -274,7 +285,7 @@ const exportCsv = () => {
|
|||||||
const rows = displayOrders.value.map((item) => [item.orderNo, item.username, item.productName, item.quantity, item.totalAmount, getStatusText(item.status), formatTime(item.createdAt)])
|
const rows = displayOrders.value.map((item) => [item.orderNo, item.username, item.productName, item.quantity, item.totalAmount, getStatusText(item.status), formatTime(item.createdAt)])
|
||||||
const header = ['订单号', '用户', '商品', '数量', '金额', '状态', '创建时间']
|
const header = ['订单号', '用户', '商品', '数量', '金额', '状态', '创建时间']
|
||||||
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
|
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
|
||||||
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([`\ufeff${csv}`], {type: 'text/csv;charset=utf-8;'})
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = URL.createObjectURL(blob)
|
link.href = URL.createObjectURL(blob)
|
||||||
link.download = `orders-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
|
link.download = `orders-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
|
||||||
@@ -297,7 +308,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -337,8 +348,13 @@ onMounted(() => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value {
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@@ -7,11 +7,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button @click="reloadData">
|
<el-button @click="reloadData">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" @click="openCreateDialog">
|
<el-button type="primary" @click="openCreateDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon>
|
||||||
|
<Plus/>
|
||||||
|
</el-icon>
|
||||||
添加商品
|
添加商品
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,21 +42,23 @@
|
|||||||
|
|
||||||
<div class="panel-card filter-card">
|
<div class="panel-card filter-card">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="query.keyword"
|
v-model="query.keyword"
|
||||||
placeholder="搜索商品名称"
|
clearable
|
||||||
clearable
|
placeholder="搜索商品名称"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon>
|
||||||
|
<Search/>
|
||||||
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-select v-model="query.category" clearable placeholder="全部分类" @change="handleSearch">
|
<el-select v-model="query.category" clearable placeholder="全部分类" @change="handleSearch">
|
||||||
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
|
<el-option v-for="item in categories" :key="item" :label="item" :value="item"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||||
<el-option label="上架" :value="1" />
|
<el-option :value="1" label="上架"/>
|
||||||
<el-option label="下架" :value="0" />
|
<el-option :value="0" label="下架"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -60,32 +66,32 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<el-table v-loading="loading" :data="products" stripe>
|
<el-table v-loading="loading" :data="products" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column label="ID" prop="id" width="80"/>
|
||||||
<el-table-column label="商品图片" width="100">
|
<el-table-column label="商品图片" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<SafeImage :src="row.imageUrl" :alt="row.name" wrapper-class="product-image" img-class="product-image" />
|
<SafeImage :alt="row.name" :src="row.imageUrl" img-class="product-image" wrapper-class="product-image"/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
|
<el-table-column label="商品名称" min-width="180" prop="name" show-overflow-tooltip/>
|
||||||
<el-table-column prop="description" label="商品描述" min-width="220" show-overflow-tooltip />
|
<el-table-column label="商品描述" min-width="220" prop="description" show-overflow-tooltip/>
|
||||||
<el-table-column prop="category" label="分类" width="120" />
|
<el-table-column label="分类" prop="category" width="120"/>
|
||||||
<el-table-column prop="price" label="价格" width="110">
|
<el-table-column label="价格" prop="price" width="110">
|
||||||
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
|
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="stock" label="库存" width="90">
|
<el-table-column label="库存" prop="stock" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
|
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column label="状态" prop="status" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '上架' : '下架' }}</el-tag>
|
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '上架' : '下架' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
<el-table-column label="创建时间" min-width="170" prop="createdAt">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="240" fixed="right">
|
<el-table-column fixed="right" label="操作" width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button text type="primary" @click="openDetail(row.id)">查看</el-button>
|
<el-button text type="primary" @click="openDetail(row.id)">查看</el-button>
|
||||||
<el-button text type="primary" @click="openEditDialog(row.id)">编辑</el-button>
|
<el-button text type="primary" @click="openEditDialog(row.id)">编辑</el-button>
|
||||||
@@ -96,13 +102,13 @@
|
|||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[10, 20, 50]"
|
||||||
:page-sizes="[10, 20, 50]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@current-change="loadProducts"
|
@current-change="loadProducts"
|
||||||
@size-change="handlePageSizeChange"
|
@size-change="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,22 +116,23 @@
|
|||||||
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '添加商品' : '编辑商品'" width="720px">
|
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '添加商品' : '编辑商品'" width="720px">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
<el-form-item label="商品名称" prop="name">
|
<el-form-item label="商品名称" prop="name">
|
||||||
<el-input v-model="form.name" placeholder="请输入商品名称" />
|
<el-input v-model="form.name" placeholder="请输入商品名称"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="商品分类" prop="category">
|
<el-form-item label="商品分类" prop="category">
|
||||||
<el-select v-model="form.category" placeholder="请选择分类" class="w-full" allow-create filterable default-first-option>
|
<el-select v-model="form.category" allow-create class="w-full" default-first-option filterable
|
||||||
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
|
placeholder="请选择分类">
|
||||||
|
<el-option v-for="item in categories" :key="item" :label="item" :value="item"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="价格" prop="price">
|
<el-form-item label="价格" prop="price">
|
||||||
<el-input-number v-model="form.price" :min="0.01" :precision="2" class="w-full" />
|
<el-input-number v-model="form.price" :min="0.01" :precision="2" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="库存" prop="stock">
|
<el-form-item label="库存" prop="stock">
|
||||||
<el-input-number v-model="form.stock" :min="0" class="w-full" />
|
<el-input-number v-model="form.stock" :min="0" class="w-full"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -136,21 +143,21 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="商品图片" prop="imageUrl">
|
<el-form-item label="商品图片" prop="imageUrl">
|
||||||
<ImageUpload v-model="form.imageUrl" />
|
<ImageUpload v-model="form.imageUrl"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="商品描述" prop="description">
|
<el-form-item label="商品描述" prop="description">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
<el-input v-model="form.description" :rows="4" maxlength="500" show-word-limit type="textarea"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="formVisible = false">取消</el-button>
|
<el-button @click="formVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
<el-button :loading="saving" type="primary" @click="submitForm">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" title="商品详情" width="760px">
|
<el-dialog v-model="detailVisible" title="商品详情" width="760px">
|
||||||
<div v-if="detail" class="detail-layout">
|
<div v-if="detail" class="detail-layout">
|
||||||
<SafeImage :src="detail.imageUrl" :alt="detail.name" wrapper-class="detail-image" img-class="detail-image" />
|
<SafeImage :alt="detail.name" :src="detail.imageUrl" img-class="detail-image" wrapper-class="detail-image"/>
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<h3>{{ detail.name }}</h3>
|
<h3>{{ detail.name }}</h3>
|
||||||
<div class="detail-price">¥{{ formatCurrency(detail.price) }}</div>
|
<div class="detail-price">¥{{ formatCurrency(detail.price) }}</div>
|
||||||
@@ -176,16 +183,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import {onMounted, reactive, ref} from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type {FormInstance, FormRules} from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import ImageUpload from '@/components/common/ImageUpload.vue'
|
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import type { AdminProductRow, AdminProductStats } from '@/types/admin'
|
import type {AdminProductRow, AdminProductStats} from '@/types/admin'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -228,9 +235,9 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
name: [{required: true, message: '请输入商品名称', trigger: 'blur'}],
|
||||||
category: [{ required: true, message: '请选择商品分类', trigger: 'change' }],
|
category: [{required: true, message: '请选择商品分类', trigger: 'change'}],
|
||||||
price: [{ required: true, message: '请输入商品价格', trigger: 'change' }],
|
price: [{required: true, message: '请输入商品价格', trigger: 'change'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
@@ -375,7 +382,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
334
community-fresh-group-buy-frontend/src/pages/admin/returns.vue
Normal file
334
community-fresh-group-buy-frontend/src/pages/admin/returns.vue
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">退货管理</h2>
|
||||||
|
<p class="page-subtitle">处理用户退货申请、审核和退款</p>
|
||||||
|
</div>
|
||||||
|
<el-button @click="reloadData">
|
||||||
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="mini-stat orange">
|
||||||
|
<div class="mini-stat__value">{{ stats.pendingCount }}</div>
|
||||||
|
<div class="mini-stat__label">待审核</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat blue">
|
||||||
|
<div class="mini-stat__value">{{ stats.approvedCount }}</div>
|
||||||
|
<div class="mini-stat__label">已同意</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat purple">
|
||||||
|
<div class="mini-stat__value">{{ stats.returningCount }}</div>
|
||||||
|
<div class="mini-stat__label">退货中</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat green">
|
||||||
|
<div class="mini-stat__value">{{ stats.completedCount }}</div>
|
||||||
|
<div class="mini-stat__label">已完成</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card filter-card">
|
||||||
|
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width: 150px" @change="loadReturns">
|
||||||
|
<el-option :value="undefined" label="全部"/>
|
||||||
|
<el-option :value="1" label="待审核"/>
|
||||||
|
<el-option :value="2" label="已同意"/>
|
||||||
|
<el-option :value="3" label="退货中"/>
|
||||||
|
<el-option :value="4" label="已完成"/>
|
||||||
|
<el-option :value="5" label="已拒绝"/>
|
||||||
|
<el-option :value="6" label="已取消"/>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadReturns">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<el-table v-loading="loading" :data="returns" stripe>
|
||||||
|
<el-table-column label="退货单号" min-width="140" prop="returnNo"/>
|
||||||
|
<el-table-column label="订单号" min-width="140" prop="orderNo"/>
|
||||||
|
<el-table-column label="用户" prop="username" width="100"/>
|
||||||
|
<el-table-column label="商品" min-width="160" prop="productName" show-overflow-tooltip/>
|
||||||
|
<el-table-column label="退款金额" prop="refundAmount" width="100">
|
||||||
|
<template #default="{ row }">¥{{ row.refundAmount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="退货原因" min-width="120" prop="reason" show-overflow-tooltip/>
|
||||||
|
<el-table-column label="状态" prop="statusText" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getReturnStatusType(row.status)" size="small">{{ row.statusText }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申请时间" min-width="170" prop="createdAt">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column fixed="right" label="操作" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="row.status === 'PENDING'" text type="primary" @click="openReviewDialog(row)">审核
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="row.status === 'RETURNING'" text type="success" @click="handleComplete(row)">确认退款
|
||||||
|
</el-button>
|
||||||
|
<el-button text @click="openDetailDialog(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="table-footer">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="size" :page-sizes="[10,20,50]" :total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper" @current-change="loadReturns"
|
||||||
|
@size-change="loadReturns"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 审核弹窗 -->
|
||||||
|
<el-dialog v-model="reviewVisible" title="退货审核" width="500px">
|
||||||
|
<div v-if="currentReturn" class="space-y-4">
|
||||||
|
<div class="text-sm"><span class="text-gray-500">退货单号:</span>{{ currentReturn.returnNo }}</div>
|
||||||
|
<div class="text-sm"><span class="text-gray-500">用户:</span>{{ currentReturn.username }}</div>
|
||||||
|
<div class="text-sm"><span class="text-gray-500">退款金额:</span><span class="text-red-500 font-semibold">¥{{
|
||||||
|
currentReturn.refundAmount
|
||||||
|
}}</span></div>
|
||||||
|
<div class="text-sm"><span class="text-gray-500">退货原因:</span>{{ currentReturn.reason }}</div>
|
||||||
|
<div v-if="currentReturn.description" class="text-sm"><span
|
||||||
|
class="text-gray-500">详细描述:</span>{{ currentReturn.description }}
|
||||||
|
</div>
|
||||||
|
<el-divider/>
|
||||||
|
<el-radio-group v-model="reviewForm.status">
|
||||||
|
<el-radio :label="2">同意退货</el-radio>
|
||||||
|
<el-radio :label="5">拒绝退货</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-input v-if="reviewForm.status === 5" v-model="reviewForm.rejectReason" :rows="3" placeholder="请输入拒绝原因"
|
||||||
|
type="textarea"/>
|
||||||
|
<el-input v-model="reviewForm.adminRemark" :rows="2" placeholder="管理员备注(选填)" type="textarea"/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="reviewVisible = false">取消</el-button>
|
||||||
|
<el-button :disabled="!reviewForm.status" type="primary" @click="submitReview">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<el-dialog v-model="detailVisible" title="退货详情" width="600px">
|
||||||
|
<div v-if="currentReturn" class="space-y-3 text-sm">
|
||||||
|
<div><span class="text-gray-500">退货单号:</span>{{ currentReturn.returnNo }}</div>
|
||||||
|
<div><span class="text-gray-500">订单号:</span>{{ currentReturn.orderNo }}</div>
|
||||||
|
<div><span class="text-gray-500">用户:</span>{{ currentReturn.username }}</div>
|
||||||
|
<div><span class="text-gray-500">商品:</span>{{ currentReturn.productName }}</div>
|
||||||
|
<div><span class="text-gray-500">退款金额:</span><span
|
||||||
|
class="text-red-500 font-semibold">¥{{ currentReturn.refundAmount }}</span></div>
|
||||||
|
<div><span class="text-gray-500">退货原因:</span>{{ currentReturn.reason }}</div>
|
||||||
|
<div v-if="currentReturn.description"><span class="text-gray-500">详细描述:</span>{{
|
||||||
|
currentReturn.description
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div><span class="text-gray-500">状态:</span>
|
||||||
|
<el-tag :type="getReturnStatusType(currentReturn.status)" size="small">{{ currentReturn.statusText }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentReturn.rejectReason"><span class="text-gray-500">拒绝原因:</span><span
|
||||||
|
class="text-red-500">{{ currentReturn.rejectReason }}</span></div>
|
||||||
|
<div v-if="currentReturn.returnTracking"><span
|
||||||
|
class="text-gray-500">物流单号:</span>{{ currentReturn.returnTracking }}
|
||||||
|
</div>
|
||||||
|
<div v-if="currentReturn.adminRemark"><span class="text-gray-500">管理员备注:</span>{{
|
||||||
|
currentReturn.adminRemark
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div><span class="text-gray-500">申请时间:</span>{{ formatTime(currentReturn.createdAt) }}</div>
|
||||||
|
<div v-if="currentReturn.reviewedAt"><span
|
||||||
|
class="text-gray-500">审核时间:</span>{{ formatTime(currentReturn.reviewedAt) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="currentReturn.shippedAt"><span
|
||||||
|
class="text-gray-500">寄出时间:</span>{{ formatTime(currentReturn.shippedAt) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="currentReturn.completedAt"><span
|
||||||
|
class="text-gray-500">完成时间:</span>{{ formatTime(currentReturn.completedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {onMounted, reactive, ref} from 'vue'
|
||||||
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
|
import {returnApi} from '@/api/modules/return'
|
||||||
|
import {normalizeOrderReturn} from '@/utils/normalizers'
|
||||||
|
import type {OrderReturn} from '@/types/api'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const size = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const returns = ref<OrderReturn[]>([])
|
||||||
|
const filters = reactive<{ status: number | undefined }>({status: undefined})
|
||||||
|
const stats = reactive({
|
||||||
|
pendingCount: 0,
|
||||||
|
approvedCount: 0,
|
||||||
|
returningCount: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
rejectedCount: 0,
|
||||||
|
cancelledCount: 0,
|
||||||
|
totalCount: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviewVisible = ref(false)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const currentReturn = ref<OrderReturn | null>(null)
|
||||||
|
const reviewForm = reactive({status: 0 as number, rejectReason: '', adminRemark: ''})
|
||||||
|
|
||||||
|
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
const getReturnStatusType = (status: string) => ({
|
||||||
|
PENDING: 'warning',
|
||||||
|
APPROVED: 'primary',
|
||||||
|
RETURNING: 'primary',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
REJECTED: 'danger',
|
||||||
|
CANCELLED: 'info'
|
||||||
|
}[status] || 'info')
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await returnApi.getStatistics()
|
||||||
|
if (res.success) Object.assign(stats, res.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载统计失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReturns = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await returnApi.getAll({
|
||||||
|
status: filters.status,
|
||||||
|
page: page.value - 1,
|
||||||
|
size: size.value,
|
||||||
|
})
|
||||||
|
if (res.success) {
|
||||||
|
returns.value = (res.data.content || []).map((item: any) => normalizeOrderReturn(item))
|
||||||
|
total.value = res.data.totalElements || 0
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReviewDialog = (row: OrderReturn) => {
|
||||||
|
currentReturn.value = row
|
||||||
|
reviewForm.status = 0
|
||||||
|
reviewForm.rejectReason = ''
|
||||||
|
reviewForm.adminRemark = ''
|
||||||
|
reviewVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetailDialog = (row: OrderReturn) => {
|
||||||
|
currentReturn.value = row
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitReview = async () => {
|
||||||
|
if (!currentReturn.value || !reviewForm.status) return
|
||||||
|
if (reviewForm.status === 5 && !reviewForm.rejectReason.trim()) {
|
||||||
|
ElMessage.warning('请输入拒绝原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await returnApi.adminReview(currentReturn.value.id, {
|
||||||
|
status: reviewForm.status,
|
||||||
|
rejectReason: reviewForm.status === 5 ? reviewForm.rejectReason : undefined,
|
||||||
|
adminRemark: reviewForm.adminRemark || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('审核完成')
|
||||||
|
reviewVisible.value = false
|
||||||
|
reloadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('审核失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = async (row: OrderReturn) => {
|
||||||
|
await ElMessageBox.confirm(`确认退款 ¥${row.refundAmount} 给用户 ${row.username}?`, '确认退款', {
|
||||||
|
confirmButtonText: '确认退款',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await returnApi.adminComplete(row.id)
|
||||||
|
ElMessage.success('退款已完成')
|
||||||
|
reloadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('确认退款失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadData = async () => {
|
||||||
|
await Promise.all([loadStats(), loadReturns()])
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
reloadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
@apply text-2xl font-bold text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
@apply text-sm text-slate-500 mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
|
color: #171715;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat__value {
|
||||||
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card {
|
||||||
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer {
|
||||||
|
@apply flex justify-end mt-4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
204
community-fresh-group-buy-frontend/src/pages/admin/reviews.vue
Normal file
204
community-fresh-group-buy-frontend/src/pages/admin/reviews.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">评价管理</h2>
|
||||||
|
<p class="page-subtitle">查看、隐藏和回复用户评价</p>
|
||||||
|
</div>
|
||||||
|
<el-button @click="reloadData">
|
||||||
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="mini-stat blue">
|
||||||
|
<div class="mini-stat__value">{{ stats.totalReviews }}</div>
|
||||||
|
<div class="mini-stat__label">评价总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat green">
|
||||||
|
<div class="mini-stat__value">{{ stats.todayReviews }}</div>
|
||||||
|
<div class="mini-stat__label">今日新增</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat orange">
|
||||||
|
<div class="mini-stat__value">{{ stats.averageRating.toFixed(1) }}</div>
|
||||||
|
<div class="mini-stat__label">平均评分</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat purple">
|
||||||
|
<div class="mini-stat__value">{{ stats.fiveStarReviews }}</div>
|
||||||
|
<div class="mini-stat__label">五星评价</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card filter-card">
|
||||||
|
<el-input v-model="keyword" clearable placeholder="搜索用户 / 商品 / 评价内容" @keyup.enter="loadReviews"/>
|
||||||
|
<el-button type="primary" @click="loadReviews">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<el-table v-loading="loading" :data="reviews" stripe>
|
||||||
|
<el-table-column label="商品" min-width="160" prop="productName" show-overflow-tooltip/>
|
||||||
|
<el-table-column label="用户" prop="username" width="120"/>
|
||||||
|
<el-table-column label="评分" prop="rating" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-rate :model-value="row.rating" disabled/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="评价内容" min-width="240" prop="content" show-overflow-tooltip/>
|
||||||
|
<el-table-column label="状态" prop="statusText" width="90"/>
|
||||||
|
<el-table-column label="回复" min-width="200" prop="adminReply" show-overflow-tooltip/>
|
||||||
|
<el-table-column label="时间" min-width="170" prop="createdAt"/>
|
||||||
|
<el-table-column label="操作" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="primary" @click="openReply(row)">回复</el-button>
|
||||||
|
<el-button :type="row.status === 1 ? 'warning' : 'success'" text @click="toggleStatus(row)">
|
||||||
|
{{ row.status === 1 ? '隐藏' : '显示' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button text type="danger" @click="removeReview(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="table-footer">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="size" :page-sizes="[10,20,50]" :total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper" @current-change="loadReviews"
|
||||||
|
@size-change="loadReviews"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="replyVisible" title="评价回复" width="600px">
|
||||||
|
<el-input v-model="replyText" :rows="5" maxlength="500" placeholder="请输入管理员回复" show-word-limit
|
||||||
|
type="textarea"/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="replyVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitReply">保存回复</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {onMounted, reactive, ref} from 'vue'
|
||||||
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
|
import {adminApi} from '@/api/modules/admin'
|
||||||
|
import type {AdminReviewRow, AdminReviewStats} from '@/types/admin'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const size = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const reviews = ref<AdminReviewRow[]>([])
|
||||||
|
const stats = reactive<AdminReviewStats>({totalReviews: 0, todayReviews: 0, averageRating: 0, fiveStarReviews: 0})
|
||||||
|
const replyVisible = ref(false)
|
||||||
|
const currentReviewId = ref<number | null>(null)
|
||||||
|
const replyText = ref('')
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
const res = await adminApi.getReviewStats()
|
||||||
|
Object.assign(stats, res.data)
|
||||||
|
}
|
||||||
|
const loadReviews = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.getReviews({page: page.value, size: size.value, keyword: keyword.value || undefined})
|
||||||
|
reviews.value = res.data.reviews
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const openReply = (row: AdminReviewRow) => {
|
||||||
|
currentReviewId.value = row.id;
|
||||||
|
replyText.value = row.adminReply || '';
|
||||||
|
replyVisible.value = true
|
||||||
|
}
|
||||||
|
const submitReply = async () => {
|
||||||
|
if (!currentReviewId.value) return
|
||||||
|
await adminApi.updateReview(currentReviewId.value, {adminReply: replyText.value, status: 1})
|
||||||
|
ElMessage.success('回复已保存')
|
||||||
|
replyVisible.value = false
|
||||||
|
loadReviews()
|
||||||
|
}
|
||||||
|
const toggleStatus = async (row: AdminReviewRow) => {
|
||||||
|
await adminApi.updateReview(row.id, {status: row.status === 1 ? 0 : 1})
|
||||||
|
ElMessage.success('状态已更新')
|
||||||
|
loadStats();
|
||||||
|
loadReviews()
|
||||||
|
}
|
||||||
|
const removeReview = async (id: number) => {
|
||||||
|
await ElMessageBox.confirm('确定删除该评价吗?', '提示', {type: 'warning'})
|
||||||
|
await adminApi.deleteReview(id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadStats();
|
||||||
|
loadReviews()
|
||||||
|
}
|
||||||
|
const reloadData = async () => {
|
||||||
|
await Promise.all([loadStats(), loadReviews()])
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
reloadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
@apply text-2xl font-bold text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
@apply text-sm text-slate-500 mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
@apply rounded-xl p-5 shadow-sm;
|
||||||
|
background: #fffaf2;
|
||||||
|
color: #171715;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat__value {
|
||||||
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card {
|
||||||
|
@apply bg-white rounded-xl p-5;
|
||||||
|
border: 1px solid #d8cebf;
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:1fr 100px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer {
|
||||||
|
@apply flex justify-end mt-4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,11 +7,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button @click="reloadData">
|
<el-button @click="reloadData">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon>
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="exportCsv">
|
<el-button @click="exportCsv">
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon>
|
||||||
|
<Download/>
|
||||||
|
</el-icon>
|
||||||
导出
|
导出
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,11 +42,15 @@
|
|||||||
|
|
||||||
<div class="panel-card filter-card">
|
<div class="panel-card filter-card">
|
||||||
<el-input v-model="query.keyword" clearable placeholder="搜索用户名 / 邮箱 / 手机号" @keyup.enter="handleSearch">
|
<el-input v-model="query.keyword" clearable placeholder="搜索用户名 / 邮箱 / 手机号" @keyup.enter="handleSearch">
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Search/>
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||||
<el-option label="正常" :value="1" />
|
<el-option :value="1" label="正常"/>
|
||||||
<el-option label="禁用" :value="0" />
|
<el-option :value="0" label="禁用"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -50,13 +58,16 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<el-table v-loading="loading" :data="users" stripe>
|
<el-table v-loading="loading" :data="users" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column label="ID" prop="id" width="80"/>
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
<el-table-column label="用户名" min-width="120" prop="username"/>
|
||||||
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
|
<el-table-column label="邮箱" min-width="180" prop="email" show-overflow-tooltip/>
|
||||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
<el-table-column label="手机号" min-width="130" prop="phone"/>
|
||||||
<el-table-column prop="role" label="角色" width="100">
|
<el-table-column label="角色" prop="role" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'">{{ row.role === 'ADMIN' ? '管理员' : '普通用户' }}</el-tag>
|
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'">{{
|
||||||
|
row.role === 'ADMIN' ? '管理员' : '普通用户'
|
||||||
|
}}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="100">
|
||||||
@@ -69,10 +80,10 @@
|
|||||||
<el-tag :type="row.isOnline ? 'success' : 'info'">{{ row.isOnline ? '在线' : '离线' }}</el-tag>
|
<el-tag :type="row.isOnline ? 'success' : 'info'">{{ row.isOnline ? '在线' : '离线' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="注册时间" min-width="170">
|
<el-table-column label="注册时间" min-width="170" prop="createdAt">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="lastLogin" label="最后登录" min-width="170">
|
<el-table-column label="最后登录" min-width="170" prop="lastLogin">
|
||||||
<template #default="{ row }">{{ row.lastLogin ? formatTime(row.lastLogin) : '-' }}</template>
|
<template #default="{ row }">{{ row.lastLogin ? formatTime(row.lastLogin) : '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column fixed="right" label="操作" width="150">
|
<el-table-column fixed="right" label="操作" width="150">
|
||||||
@@ -85,13 +96,13 @@
|
|||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[10, 20, 50]"
|
||||||
:page-sizes="[10, 20, 50]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@current-change="loadUsers"
|
@current-change="loadUsers"
|
||||||
@size-change="handlePageSizeChange"
|
@size-change="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,12 +123,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import {onMounted, reactive, ref} from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {ElMessage, ElMessageBox} from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { adminApi } from '@/api/modules/admin'
|
import {adminApi} from '@/api/modules/admin'
|
||||||
import type { AdminUserRow, AdminUserStats } from '@/types/admin'
|
import type {AdminUserRow, AdminUserStats} from '@/types/admin'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
@@ -201,7 +212,7 @@ const exportCsv = () => {
|
|||||||
const rows = users.value.map((item) => [item.id, item.username, item.email, item.phone, item.role, item.statusText, item.isOnline ? '在线' : '离线', formatTime(item.createdAt), item.lastLogin ? formatTime(item.lastLogin) : ''])
|
const rows = users.value.map((item) => [item.id, item.username, item.email, item.phone, item.role, item.statusText, item.isOnline ? '在线' : '离线', formatTime(item.createdAt), item.lastLogin ? formatTime(item.lastLogin) : ''])
|
||||||
const header = ['ID', '用户名', '邮箱', '手机号', '角色', '状态', '在线', '注册时间', '最后登录']
|
const header = ['ID', '用户名', '邮箱', '手机号', '角色', '状态', '在线', '注册时间', '最后登录']
|
||||||
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
|
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
|
||||||
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([`\ufeff${csv}`], {type: 'text/csv;charset=utf-8;'})
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = URL.createObjectURL(blob)
|
link.href = URL.createObjectURL(blob)
|
||||||
link.download = `users-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
|
link.download = `users-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
|
||||||
@@ -218,7 +229,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -258,8 +269,13 @@ onMounted(() => {
|
|||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
|
||||||
&__value { @apply text-3xl font-bold; }
|
&__value {
|
||||||
&__label { @apply text-sm opacity-90 mt-2; }
|
@apply text-3xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
@apply text-sm opacity-90 mt-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
@@ -4,16 +4,20 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold flex items-center">
|
<h1 class="text-3xl font-bold flex items-center">
|
||||||
<el-icon class="text-blue-500 mr-2"><ShoppingCart /></el-icon>
|
<el-icon class="text-blue-500 mr-2">
|
||||||
|
<ShoppingCart/>
|
||||||
|
</el-icon>
|
||||||
购物车
|
购物车
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="cartStore.loading" class="text-center py-12">
|
<div v-if="cartStore.loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="cartStore.items.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
<div v-else-if="cartStore.items.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||||
<el-empty description="购物车空空如也">
|
<el-empty description="购物车空空如也">
|
||||||
<el-button type="primary" @click="router.push('/products')">
|
<el-button type="primary" @click="router.push('/products')">
|
||||||
@@ -21,60 +25,64 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<!-- 左侧:购物车列表 -->
|
<!-- 左侧:购物车列表 -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<div class="bg-white rounded-lg shadow-sm">
|
<div class="bg-white rounded-lg shadow-sm">
|
||||||
<!-- 全选栏 -->
|
<!-- 全选栏 -->
|
||||||
<div class="border-b px-6 py-4 flex justify-between items-center">
|
<div class="border-b px-6 py-4 flex justify-between items-center">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-model="selectAll"
|
v-model="selectAll"
|
||||||
:indeterminate="indeterminate"
|
:indeterminate="indeterminate"
|
||||||
@change="handleSelectAll"
|
@change="handleSelectAll"
|
||||||
>
|
>
|
||||||
全选({{ cartStore.selectedCount }}/{{ cartStore.itemCount }})
|
全选({{ cartStore.selectedCount }}/{{ cartStore.itemCount }})
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
text
|
:disabled="cartStore.selectedCount === 0"
|
||||||
type="danger"
|
text
|
||||||
:disabled="cartStore.selectedCount === 0"
|
type="danger"
|
||||||
@click="handleBatchRemove"
|
@click="handleBatchRemove"
|
||||||
>
|
>
|
||||||
<el-icon class="mr-1"><Delete /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Delete/>
|
||||||
|
</el-icon>
|
||||||
删除选中
|
删除选中
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 商品列表 -->
|
<!-- 商品列表 -->
|
||||||
<div class="divide-y">
|
<div class="divide-y">
|
||||||
<div
|
<div
|
||||||
v-for="item in cartStore.items"
|
v-for="item in cartStore.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="p-6 hover:bg-gray-50 transition-colors"
|
class="p-6 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<!-- 选择框 -->
|
<!-- 选择框 -->
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-model="item.selected"
|
v-model="item.selected"
|
||||||
@change="handleSelectItem(item.id)"
|
@change="handleSelectItem(item.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 商品图片 -->
|
<!-- 商品图片 -->
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100" img-class="w-24 h-24 object-cover rounded-lg" />
|
<SafeImage :alt="item.productName" :src="item.productImage"
|
||||||
|
img-class="w-24 h-24 object-cover rounded-lg"
|
||||||
|
wrapper-class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100"/>
|
||||||
|
|
||||||
<!-- 商品信息 -->
|
<!-- 商品信息 -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="font-semibold mb-2">
|
<h3 class="font-semibold mb-2">
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/product/${item.productId}`"
|
:to="`/product/${item.productId}`"
|
||||||
class="hover:text-primary-500"
|
class="hover:text-primary-500"
|
||||||
>
|
>
|
||||||
{{ item.productName }}
|
{{ item.productName }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-red-500 font-bold text-lg">
|
<span class="text-red-500 font-bold text-lg">
|
||||||
@@ -84,28 +92,28 @@
|
|||||||
库存: {{ item.stock }}
|
库存: {{ item.stock }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 数量选择 -->
|
<!-- 数量选择 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="item.quantity"
|
v-model="item.quantity"
|
||||||
:min="1"
|
:max="item.stock"
|
||||||
:max="item.stock"
|
:min="1"
|
||||||
size="small"
|
size="small"
|
||||||
@change="handleQuantityChange(item.id, item.quantity)"
|
@change="handleQuantityChange(item.id, item.quantity)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
text
|
size="small"
|
||||||
type="danger"
|
text
|
||||||
size="small"
|
type="danger"
|
||||||
@click="handleRemoveItem(item.id)"
|
@click="handleRemoveItem(item.id)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 小计 -->
|
<!-- 小计 -->
|
||||||
<div class="mt-2 text-right">
|
<div class="mt-2 text-right">
|
||||||
<span class="text-sm text-gray-500">小计:</span>
|
<span class="text-sm text-gray-500">小计:</span>
|
||||||
@@ -117,26 +125,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
<!-- 底部操作栏 -->
|
||||||
<div class="border-t px-6 py-4 flex justify-between items-center">
|
<div class="border-t px-6 py-4 flex justify-between items-center">
|
||||||
<el-button text @click="handleClearCart">
|
<el-button text @click="handleClearCart">
|
||||||
<el-icon class="mr-1"><Delete /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Delete/>
|
||||||
|
</el-icon>
|
||||||
清空购物车
|
清空购物车
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<el-button text type="primary" @click="router.push('/products')">
|
<el-button text type="primary" @click="router.push('/products')">
|
||||||
继续购物
|
继续购物
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:结算信息 -->
|
<!-- 右侧:结算信息 -->
|
||||||
<div class="lg:col-span-1">
|
<div class="lg:col-span-1">
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 sticky top-20">
|
<div class="bg-white rounded-lg shadow-sm p-6 sticky top-20">
|
||||||
<h3 class="text-lg font-semibold mb-4">订单结算</h3>
|
<h3 class="text-lg font-semibold mb-4">订单结算</h3>
|
||||||
|
|
||||||
<!-- 费用明细 -->
|
<!-- 费用明细 -->
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -152,7 +162,7 @@
|
|||||||
<span class="text-red-500">-¥0.00</span>
|
<span class="text-red-500">-¥0.00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 总计 -->
|
<!-- 总计 -->
|
||||||
<div class="border-t pt-4 mb-6">
|
<div class="border-t pt-4 mb-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
@@ -165,29 +175,31 @@
|
|||||||
已选 {{ cartStore.selectedCount }} 件,共 {{ cartStore.totalQuantity }} 件
|
已选 {{ cartStore.selectedCount }} 件,共 {{ cartStore.totalQuantity }} 件
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 结算按钮 -->
|
<!-- 结算按钮 -->
|
||||||
<el-button
|
<el-button
|
||||||
type="danger"
|
:disabled="cartStore.selectedCount === 0"
|
||||||
size="large"
|
class="w-full"
|
||||||
class="w-full"
|
size="large"
|
||||||
:disabled="cartStore.selectedCount === 0"
|
type="danger"
|
||||||
@click="handleCheckout"
|
@click="handleCheckout"
|
||||||
>
|
>
|
||||||
去结算({{ cartStore.selectedCount }})
|
去结算({{ cartStore.selectedCount }})
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<!-- 推荐商品 -->
|
<!-- 推荐商品 -->
|
||||||
<div class="mt-6 pt-6 border-t">
|
<div class="mt-6 pt-6 border-t">
|
||||||
<h4 class="font-semibold mb-3">为你推荐</h4>
|
<h4 class="font-semibold mb-3">为你推荐</h4>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="item in recommendProducts"
|
v-for="item in recommendProducts"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded"
|
class="flex gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded"
|
||||||
@click="router.push(`/product/${item.id}`)"
|
@click="router.push(`/product/${item.id}`)"
|
||||||
>
|
>
|
||||||
<SafeImage :src="item.imageUrl" :alt="item.name" wrapper-class="w-16 h-16 rounded overflow-hidden bg-gray-100" img-class="w-16 h-16 object-cover rounded" />
|
<SafeImage :alt="item.name" :src="item.imageUrl"
|
||||||
|
img-class="w-16 h-16 object-cover rounded"
|
||||||
|
wrapper-class="w-16 h-16 rounded overflow-hidden bg-gray-100"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm line-clamp-2">{{ item.name }}</p>
|
<p class="text-sm line-clamp-2">{{ item.name }}</p>
|
||||||
<p class="text-red-500 font-semibold">¥{{ item.price }}</p>
|
<p class="text-red-500 font-semibold">¥{{ item.price }}</p>
|
||||||
@@ -202,14 +214,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import { cartApi } from '@/api/modules/cart'
|
import {cartApi} from '@/api/modules/cart'
|
||||||
import type { Product } from '@/types/api'
|
import type {Product} from '@/types/api'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -256,24 +268,24 @@ const handleRemoveItem = async (itemId: string) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
|
|
||||||
await cartStore.removeItem(itemId)
|
await cartStore.removeItem(itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除
|
// 批量删除
|
||||||
const handleBatchRemove = async () => {
|
const handleBatchRemove = async () => {
|
||||||
if (cartStore.selectedCount === 0) return
|
if (cartStore.selectedCount === 0) return
|
||||||
|
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定要删除选中的 ${cartStore.selectedCount} 件商品吗?`,
|
`确定要删除选中的 ${cartStore.selectedCount} 件商品吗?`,
|
||||||
'提示',
|
'提示',
|
||||||
{
|
{
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await cartStore.removeSelected()
|
await cartStore.removeSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +296,7 @@ const handleClearCart = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
|
|
||||||
await cartStore.clearCart()
|
await cartStore.clearCart()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +338,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.cart-page {
|
.cart-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2,20 +2,24 @@
|
|||||||
<div class="error-404">
|
<div class="error-404">
|
||||||
<div class="container mx-auto px-4 py-16 text-center">
|
<div class="container mx-auto px-4 py-16 text-center">
|
||||||
<el-icon :size="120" class="text-gray-300 mb-8">
|
<el-icon :size="120" class="text-gray-300 mb-8">
|
||||||
<Warning />
|
<Warning/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
|
|
||||||
<h1 class="text-6xl font-bold text-gray-800 mb-4">404</h1>
|
<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-2xl text-gray-600 mb-8">页面未找到</p>
|
||||||
<p class="text-gray-500 mb-8">抱歉,您访问的页面不存在或已被移除</p>
|
<p class="text-gray-500 mb-8">抱歉,您访问的页面不存在或已被移除</p>
|
||||||
|
|
||||||
<div class="space-x-4">
|
<div class="space-x-4">
|
||||||
<el-button type="primary" size="large" @click="router.push('/')">
|
<el-button size="large" type="primary" @click="router.push('/')">
|
||||||
<el-icon class="mr-2"><HomeFilled /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<HomeFilled/>
|
||||||
|
</el-icon>
|
||||||
返回首页
|
返回首页
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="large" @click="router.back()">
|
<el-button size="large" @click="router.back()">
|
||||||
<el-icon class="mr-2"><Back /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<Back/>
|
||||||
|
</el-icon>
|
||||||
返回上一页
|
返回上一页
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,13 +27,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.error-404 {
|
.error-404 {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flashsale-detail-page">
|
<div class="flashsale-detail-page">
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item :to="{ path: '/flashsale' }">限时活动</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/flashsale' }">限时活动</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>{{ flashSale?.productName || '详情' }}</el-breadcrumb-item>
|
<el-breadcrumb-item>{{ flashSale?.productName || '详情' }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,14 +24,16 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="flashSale.productImageUrl"
|
:alt="flashSale.productName"
|
||||||
:alt="flashSale.productName"
|
:src="flashSale.productImageUrl"
|
||||||
wrapper-class="w-full rounded-lg overflow-hidden bg-gray-100"
|
img-class="w-full rounded-lg object-cover"
|
||||||
img-class="w-full rounded-lg object-cover"
|
wrapper-class="w-full rounded-lg overflow-hidden bg-gray-100"
|
||||||
/>
|
/>
|
||||||
<div class="absolute top-4 left-4">
|
<div class="absolute top-4 left-4">
|
||||||
<el-tag :type="statusType" size="large" effect="dark">
|
<el-tag :type="statusType" effect="dark" size="large">
|
||||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,33 +59,45 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<span class="text-gray-600">库存情况</span>
|
<span class="text-gray-600">库存情况</span>
|
||||||
<span class="text-sm text-gray-500">剩余 {{ flashSale.remainingStock }} / {{ flashSale.flashStock }} 件</span>
|
<span class="text-sm text-gray-500">剩余 {{ flashSale.remainingStock }} / {{
|
||||||
|
flashSale.flashStock
|
||||||
|
}} 件</span>
|
||||||
</div>
|
</div>
|
||||||
<el-progress :percentage="stockPercent" :stroke-width="10" :color="progressColor" />
|
<el-progress :color="progressColor" :percentage="stockPercent" :stroke-width="10"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="flashSale.status === 'ACTIVE'" class="mb-6">
|
<div v-if="flashSale.status === 'ACTIVE'" class="mb-6">
|
||||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
<p class="text-sm text-gray-600 mb-2">距离结束还有</p>
|
<p class="text-sm text-gray-600 mb-2">距离结束还有</p>
|
||||||
<CountDown :end-time="endTime" @finish="handleFinish" />
|
<CountDown :end-time="endTime" @finish="handleFinish"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="note-card mb-6 p-4 rounded-lg">
|
<div class="note-card mb-6 p-4 rounded-lg">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<el-icon class="mr-2"><InfoFilled /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<InfoFilled/>
|
||||||
|
</el-icon>
|
||||||
<span>每人限购 {{ flashSale.limitPerUser }} 件</span>
|
<span>每人限购 {{ flashSale.limitPerUser }} 件</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<el-button type="primary" size="large" class="w-full" :disabled="!canParticipate" :loading="participating" @click="handleParticipate">
|
<el-button :disabled="!canParticipate" :loading="participating" class="w-full" size="large" type="primary"
|
||||||
<el-icon class="mr-2"><Lightning /></el-icon>
|
@click="handleParticipate">
|
||||||
|
<el-icon class="mr-2">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<el-button size="large" class="flex-1" @click="handleViewProduct">查看商品详情</el-button>
|
<el-button class="flex-1" size="large" @click="handleViewProduct">查看商品详情</el-button>
|
||||||
<el-button size="large" class="flex-1" @click="handleShare"><el-icon class="mr-1"><Share /></el-icon>分享活动</el-button>
|
<el-button class="flex-1" size="large" @click="handleShare">
|
||||||
|
<el-icon class="mr-1">
|
||||||
|
<Share/>
|
||||||
|
</el-icon>
|
||||||
|
分享活动
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,15 +122,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import CountDown from '@/components/business/CountDown.vue'
|
import CountDown from '@/components/business/CountDown.vue'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
import {flashsaleApi} from '@/api/modules/flashsale'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { FlashSale } from '@/types/api'
|
import type {FlashSale} from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -130,21 +146,28 @@ const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
|||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
if (!flashSale.value) return 'info'
|
if (!flashSale.value) return 'info'
|
||||||
switch (flashSale.value.status) {
|
switch (flashSale.value.status) {
|
||||||
case 'UPCOMING': return 'warning'
|
case 'UPCOMING':
|
||||||
case 'ACTIVE': return 'danger'
|
return 'warning'
|
||||||
case 'ENDED': return 'info'
|
case 'ACTIVE':
|
||||||
default: return 'info'
|
return 'danger'
|
||||||
|
case 'ENDED':
|
||||||
|
return 'info'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
if (!flashSale.value) return ''
|
if (!flashSale.value) return ''
|
||||||
switch (flashSale.value.status) {
|
switch (flashSale.value.status) {
|
||||||
case 'UPCOMING': return '即将开始'
|
case 'UPCOMING':
|
||||||
|
return '即将开始'
|
||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
return '进行中'
|
return '进行中'
|
||||||
case 'ENDED': return '已结束'
|
case 'ENDED':
|
||||||
default: return ''
|
return '已结束'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -198,7 +221,7 @@ const loadDetail = async () => {
|
|||||||
const handleParticipate = async () => {
|
const handleParticipate = async () => {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
ElMessage.warning('请先登录')
|
ElMessage.warning('请先登录')
|
||||||
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
router.push({path: '/login', query: {redirect: route.fullPath}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!flashSale.value || !canParticipate.value) return
|
if (!flashSale.value || !canParticipate.value) return
|
||||||
@@ -211,7 +234,7 @@ const handleParticipate = async () => {
|
|||||||
|
|
||||||
participating.value = true
|
participating.value = true
|
||||||
try {
|
try {
|
||||||
const res = await flashsaleApi.participate({ flashSaleId: flashSale.value.id, quantity: 1 })
|
const res = await flashsaleApi.participate({flashSaleId: flashSale.value.id, quantity: 1})
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
ElMessage.success('抢购成功')
|
ElMessage.success('抢购成功')
|
||||||
router.push(`/order/${res.data.orderId}`)
|
router.push(`/order/${res.data.orderId}`)
|
||||||
@@ -241,7 +264,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.flashsale-detail-page {
|
.flashsale-detail-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
||||||
<el-icon class="page-icon mr-2"><Lightning /></el-icon>
|
<el-icon class="page-icon mr-2">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
限时活动
|
限时活动
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600">限时抢购,先到先得</p>
|
<p class="text-gray-600">限时抢购,先到先得</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
@@ -20,98 +22,110 @@
|
|||||||
<el-radio-button label="ACTIVE">进行中</el-radio-button>
|
<el-radio-button label="ACTIVE">进行中</el-radio-button>
|
||||||
<el-radio-button label="ENDED">已结束</el-radio-button>
|
<el-radio-button label="ENDED">已结束</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
|
||||||
<!-- 排序 -->
|
<!-- 排序 -->
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.sort"
|
v-model="filters.sort"
|
||||||
placeholder="排序方式"
|
placeholder="排序方式"
|
||||||
style="width: 150px"
|
style="width: 150px"
|
||||||
@change="loadFlashSales"
|
@change="loadFlashSales"
|
||||||
>
|
>
|
||||||
<el-option label="开始时间" value="startTime" />
|
<el-option label="开始时间" value="startTime"/>
|
||||||
<el-option label="结束时间" value="endTime" />
|
<el-option label="结束时间" value="endTime"/>
|
||||||
<el-option label="价格从低到高" value="flashPrice" />
|
<el-option label="价格从低到高" value="flashPrice"/>
|
||||||
<el-option label="折扣力度" value="discount" />
|
<el-option label="折扣力度" value="discount"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
placeholder="搜索商品名称"
|
clearable
|
||||||
style="width: 200px"
|
placeholder="搜索商品名称"
|
||||||
clearable
|
style="width: 200px"
|
||||||
@keyup.enter="loadFlashSales"
|
@keyup.enter="loadFlashSales"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<el-icon class="cursor-pointer" @click="loadFlashSales">
|
<el-icon class="cursor-pointer" @click="loadFlashSales">
|
||||||
<Search />
|
<Search/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<el-button @click="handleRefresh">
|
<el-button @click="handleRefresh">
|
||||||
<el-icon class="mr-1"><Refresh /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Refresh/>
|
||||||
|
</el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
<!-- 统计信息 -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div class="stat-card tone-1">
|
<div class="stat-card tone-1">
|
||||||
<div class="stat-value">{{ statistics.upcoming }}</div>
|
<div class="stat-value">{{ statistics.upcoming }}</div>
|
||||||
<div class="stat-label">即将开始</div>
|
<div class="stat-label">即将开始</div>
|
||||||
<el-icon :size="30" class="stat-icon"><Clock /></el-icon>
|
<el-icon :size="30" class="stat-icon">
|
||||||
|
<Clock/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card tone-2">
|
<div class="stat-card tone-2">
|
||||||
<div class="stat-value">{{ statistics.active }}</div>
|
<div class="stat-value">{{ statistics.active }}</div>
|
||||||
<div class="stat-label">正在进行</div>
|
<div class="stat-label">正在进行</div>
|
||||||
<el-icon :size="30" class="stat-icon"><Lightning /></el-icon>
|
<el-icon :size="30" class="stat-icon">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card tone-3">
|
<div class="stat-card tone-3">
|
||||||
<div class="stat-value">{{ statistics.participated }}</div>
|
<div class="stat-value">{{ statistics.participated }}</div>
|
||||||
<div class="stat-label">我的参与</div>
|
<div class="stat-label">我的参与</div>
|
||||||
<el-icon :size="30" class="stat-icon"><Trophy /></el-icon>
|
<el-icon :size="30" class="stat-icon">
|
||||||
|
<Trophy/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card tone-4">
|
<div class="stat-card tone-4">
|
||||||
<div class="stat-value">{{ statistics.success }}</div>
|
<div class="stat-value">{{ statistics.success }}</div>
|
||||||
<div class="stat-label">抢购成功</div>
|
<div class="stat-label">抢购成功</div>
|
||||||
<el-icon :size="30" class="stat-icon"><SuccessFilled /></el-icon>
|
<el-icon :size="30" class="stat-icon">
|
||||||
|
<SuccessFilled/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 限时活动列表 -->
|
<!-- 限时活动列表 -->
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="flashSales.length === 0" class="text-center py-12">
|
<div v-else-if="flashSales.length === 0" class="text-center py-12">
|
||||||
<el-empty description="暂无限时活动"/>
|
<el-empty description="暂无限时活动"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<FlashSaleCard
|
<FlashSaleCard
|
||||||
v-for="item in flashSales"
|
v-for="item in flashSales"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:data="item"
|
:data="item"
|
||||||
@participate="handleParticipate"
|
@participate="handleParticipate"
|
||||||
@refresh="loadFlashSales"
|
@refresh="loadFlashSales"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="mt-8 flex justify-center">
|
<div class="mt-8 flex justify-center">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[12, 24, 36, 48]"
|
||||||
:page-sizes="[12, 24, 36, 48]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@size-change="loadFlashSales"
|
@size-change="loadFlashSales"
|
||||||
@current-change="loadFlashSales"
|
@current-change="loadFlashSales"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,14 +133,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import {ref, reactive, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
|
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
|
||||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
import {flashsaleApi} from '@/api/modules/flashsale'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { FlashSale } from '@/types/api'
|
import type {FlashSale} from '@/types/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -200,7 +214,7 @@ const handleParticipate = async (flashSaleId: number) => {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先检查资格
|
// 先检查资格
|
||||||
try {
|
try {
|
||||||
const res = await flashsaleApi.checkEligibility(flashSaleId)
|
const res = await flashsaleApi.checkEligibility(flashSaleId)
|
||||||
@@ -208,14 +222,14 @@ const handleParticipate = async (flashSaleId: number) => {
|
|||||||
// 确认对话框
|
// 确认对话框
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'确定要参与这个限时活动吗?',
|
'确定要参与这个限时活动吗?',
|
||||||
'提示',
|
'提示',
|
||||||
{
|
{
|
||||||
confirmButtonText: '立即抢购',
|
confirmButtonText: '立即抢购',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 跳转到详情页参与
|
// 跳转到详情页参与
|
||||||
router.push(`/flashsale/${flashSaleId}`)
|
router.push(`/flashsale/${flashSaleId}`)
|
||||||
} else {
|
} else {
|
||||||
@@ -239,7 +253,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.flashsale-page {
|
.flashsale-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -255,11 +269,11 @@ onMounted(() => {
|
|||||||
color: #171715;
|
color: #171715;
|
||||||
border: 1px solid #d8cebf;
|
border: 1px solid #d8cebf;
|
||||||
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
@apply text-2xl font-bold;
|
@apply text-2xl font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
@apply text-sm mt-1;
|
@apply text-sm mt-1;
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
<div class="page-container py-8">
|
<div class="page-container py-8">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>{{ detail?.productName || '加载中...' }}</el-breadcrumb-item>
|
<el-breadcrumb-item>{{ detail?.productName || '加载中...' }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-20">
|
<div v-if="loading" class="text-center py-20">
|
||||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
<el-icon :size="32" class="is-loading">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
@@ -17,49 +19,60 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="detail.productImageUrl"
|
:alt="detail.productName"
|
||||||
:alt="detail.productName"
|
:src="detail.productImageUrl"
|
||||||
wrapper-class="w-full h-96 rounded-2xl overflow-hidden"
|
img-class="w-full h-96 object-cover"
|
||||||
img-class="w-full h-96 object-cover"
|
wrapper-class="w-full h-96 rounded-2xl overflow-hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<el-tag :type="statusType" effect="dark" class="mb-3">{{ detail.statusDescription }}</el-tag>
|
<el-tag :type="statusType" class="mb-3" effect="dark">{{ detail.statusDescription }}</el-tag>
|
||||||
<h1 class="text-2xl font-bold mb-4">{{ detail.productName }}</h1>
|
<h1 class="text-2xl font-bold mb-4">{{ detail.productName }}</h1>
|
||||||
|
|
||||||
<div class="price-section mb-4">
|
<div class="price-section mb-4">
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
<span class="text-3xl font-bold" style="color: #171715">¥{{ detail.groupPrice }}</span>
|
<span class="text-3xl font-bold" style="color: #171715">¥{{ detail.groupPrice }}</span>
|
||||||
<span class="text-lg text-gray-400 line-through">¥{{ detail.productPrice }}</span>
|
<span class="text-lg text-gray-400 line-through">¥{{ detail.productPrice }}</span>
|
||||||
<el-tag type="danger" size="small">省 ¥{{ detail.discount }}</el-tag>
|
<el-tag size="small" type="danger">省 ¥{{ detail.discount }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-section space-y-3 mb-6">
|
<div class="info-section space-y-3 mb-6">
|
||||||
<div class="flex items-center text-gray-600">
|
<div class="flex items-center text-gray-600">
|
||||||
<el-icon class="mr-2"><User /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<User/>
|
||||||
|
</el-icon>
|
||||||
<span>{{ detail.requiredMembers }} 人成团</span>
|
<span>{{ detail.requiredMembers }} 人成团</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-gray-600">
|
<div class="flex items-center text-gray-600">
|
||||||
<el-icon class="mr-2"><Timer /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<Timer/>
|
||||||
|
</el-icon>
|
||||||
<span>开团后 {{ detail.durationMinutes }} 分钟内有效</span>
|
<span>开团后 {{ detail.durationMinutes }} 分钟内有效</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-gray-600">
|
<div class="flex items-center text-gray-600">
|
||||||
<el-icon class="mr-2"><Box /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<Box/>
|
||||||
|
</el-icon>
|
||||||
<span>剩余库存: {{ detail.remainingStock }} / {{ detail.totalStock }}</span>
|
<span>剩余库存: {{ detail.remainingStock }} / {{ detail.totalStock }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-gray-600">
|
<div class="flex items-center text-gray-600">
|
||||||
<el-icon class="mr-2"><Warning /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<Warning/>
|
||||||
|
</el-icon>
|
||||||
<span>每人限购 {{ detail.maxPerUser }} 件</span>
|
<span>每人限购 {{ detail.maxPerUser }} 件</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-progress :percentage="stockPercent" :stroke-width="8" :show-text="false" :color="progressColor" class="mb-6" />
|
<el-progress :color="progressColor" :percentage="stockPercent" :show-text="false" :stroke-width="8"
|
||||||
|
class="mb-6"/>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<el-button type="primary" size="large" :disabled="!canJoin" @click="handleCreateGroup" :loading="joining">
|
<el-button :disabled="!canJoin" :loading="joining" size="large" type="primary" @click="handleCreateGroup">
|
||||||
<el-icon class="mr-1"><Connection /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
一键开团
|
一键开团
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="large" @click="$router.push(`/product/${detail.productId}`)">
|
<el-button size="large" @click="$router.push(`/product/${detail.productId}`)">
|
||||||
@@ -88,11 +101,12 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="formingGroups.length === 0" class="text-center py-10">
|
<div v-if="formingGroups.length === 0" class="text-center py-10">
|
||||||
<el-empty description="暂无进行中的团组,快来开团吧!" />
|
<el-empty description="暂无进行中的团组,快来开团吧!"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div v-for="group in formingGroups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
|
<div v-for="group in formingGroups" :key="group.id"
|
||||||
|
class="group-item p-4 rounded-xl flex items-center justify-between"
|
||||||
style="background: #fffaf2; border: 1px solid #e8e0d4">
|
style="background: #fffaf2; border: 1px solid #e8e0d4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
||||||
@@ -100,7 +114,7 @@
|
|||||||
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
|
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
还差 {{ group.requiredMembers - group.currentMembers }} 人 |
|
还差 {{ group.requiredMembers - group.currentMembers }} 人 |
|
||||||
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroups" />
|
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroups"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +124,7 @@
|
|||||||
{{ m.username ? m.username[0] : '?' }}
|
{{ m.username ? m.username[0] : '?' }}
|
||||||
</el-avatar>
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" size="small" @click="handleJoinGroup(group.id)" :loading="joining">
|
<el-button :loading="joining" size="small" type="primary" @click="handleJoinGroup(group.id)">
|
||||||
参团
|
参团
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,14 +140,17 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-for="group in completedGroups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
|
<div v-for="group in completedGroups" :key="group.id"
|
||||||
|
class="group-item p-4 rounded-xl flex items-center justify-between"
|
||||||
style="background: #f8f8f6; border: 1px solid #e8e0d4; opacity: 0.85">
|
style="background: #f8f8f6; border: 1px solid #e8e0d4; opacity: 0.85">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
|
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
|
||||||
<div class="text-sm text-green-600">
|
<div class="text-sm text-green-600">
|
||||||
<el-icon class="mr-1"><CircleCheckFilled /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<CircleCheckFilled/>
|
||||||
|
</el-icon>
|
||||||
已成团 · {{ group.currentMembers }}/{{ group.requiredMembers }} 人
|
已成团 · {{ group.currentMembers }}/{{ group.requiredMembers }} 人
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +161,7 @@
|
|||||||
{{ m.username ? m.username[0] : '?' }}
|
{{ m.username ? m.username[0] : '?' }}
|
||||||
</el-avatar>
|
</el-avatar>
|
||||||
</div>
|
</div>
|
||||||
<el-tag type="success" size="small">已成团</el-tag>
|
<el-tag size="small" type="success">已成团</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,13 +171,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { Loading, CircleCheckFilled } from '@element-plus/icons-vue'
|
import {Loading, CircleCheckFilled} from '@element-plus/icons-vue'
|
||||||
import type { GroupBuying, GroupBuyingGroup } from '@/types/api'
|
import type {GroupBuying, GroupBuyingGroup} from '@/types/api'
|
||||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
import {groupbuyingApi} from '@/api/modules/groupbuying'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import CountDown from '@/components/business/CountDown.vue'
|
import CountDown from '@/components/business/CountDown.vue'
|
||||||
|
|
||||||
@@ -178,10 +195,14 @@ const completedGroups = computed(() => groups.value.filter(g => g.status === 'SU
|
|||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
switch (detail.value?.status) {
|
switch (detail.value?.status) {
|
||||||
case 'UPCOMING': return 'warning'
|
case 'UPCOMING':
|
||||||
case 'ACTIVE': return 'success'
|
return 'warning'
|
||||||
case 'ENDED': return 'info'
|
case 'ACTIVE':
|
||||||
default: return 'info'
|
return 'success'
|
||||||
|
case 'ENDED':
|
||||||
|
return 'info'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,7 +230,7 @@ const loadDetail = async () => {
|
|||||||
|
|
||||||
const loadGroups = async () => {
|
const loadGroups = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await groupbuyingApi.getGroups(id.value, { page: 0, size: 50 })
|
const res = await groupbuyingApi.getGroups(id.value, {page: 0, size: 50})
|
||||||
groups.value = res.data.content
|
groups.value = res.data.content
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载团组列表失败', e)
|
console.error('加载团组列表失败', e)
|
||||||
@@ -219,7 +240,7 @@ const loadGroups = async () => {
|
|||||||
const handleCreateGroup = async () => {
|
const handleCreateGroup = async () => {
|
||||||
joining.value = true
|
joining.value = true
|
||||||
try {
|
try {
|
||||||
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value })
|
const res = await groupbuyingApi.joinGroup({groupBuyingId: id.value})
|
||||||
ElMessage.success(res.data.message || '开团成功')
|
ElMessage.success(res.data.message || '开团成功')
|
||||||
router.push(`/groupbuying/group/${res.data.groupId}`)
|
router.push(`/groupbuying/group/${res.data.groupId}`)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -232,7 +253,7 @@ const handleCreateGroup = async () => {
|
|||||||
const handleJoinGroup = async (groupId: number) => {
|
const handleJoinGroup = async (groupId: number) => {
|
||||||
joining.value = true
|
joining.value = true
|
||||||
try {
|
try {
|
||||||
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value, groupId })
|
const res = await groupbuyingApi.joinGroup({groupBuyingId: id.value, groupId})
|
||||||
ElMessage.success(res.data.message || '加入成功')
|
ElMessage.success(res.data.message || '加入成功')
|
||||||
router.push(`/groupbuying/group/${res.data.groupId}`)
|
router.push(`/groupbuying/group/${res.data.groupId}`)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -245,7 +266,7 @@ const handleJoinGroup = async (groupId: number) => {
|
|||||||
onMounted(loadDetail)
|
onMounted(loadDetail)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.price-section {
|
.price-section {
|
||||||
@apply p-4 rounded-xl;
|
@apply p-4 rounded-xl;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
@@ -2,37 +2,46 @@
|
|||||||
<div class="page-container py-8">
|
<div class="page-container py-8">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/groupbuying' }">拼团活动</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>团组详情</el-breadcrumb-item>
|
<el-breadcrumb-item>团组详情</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-20">
|
<div v-if="loading" class="text-center py-20">
|
||||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
<el-icon :size="32" class="is-loading">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="group">
|
<template v-else-if="group">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<!-- 团组状态 -->
|
<!-- 团组状态 -->
|
||||||
<div class="status-section text-center mb-8 p-8 rounded-2xl" style="background: #fffaf2; border: 1px solid #e8e0d4">
|
<div class="status-section text-center mb-8 p-8 rounded-2xl"
|
||||||
<el-tag :type="statusType" effect="dark" size="large" class="mb-4">{{ group.statusDescription }}</el-tag>
|
style="background: #fffaf2; border: 1px solid #e8e0d4">
|
||||||
|
<el-tag :type="statusType" class="mb-4" effect="dark" size="large">{{ group.statusDescription }}</el-tag>
|
||||||
|
|
||||||
<h2 class="text-xl font-bold mb-2">{{ group.groupBuying?.productName }}</h2>
|
<h2 class="text-xl font-bold mb-2">{{ group.groupBuying?.productName }}</h2>
|
||||||
<div class="text-2xl font-bold mb-4" style="color: #171715">¥{{ group.groupBuying?.groupPrice }}</div>
|
<div class="text-2xl font-bold mb-4" style="color: #171715">¥{{ group.groupBuying?.groupPrice }}</div>
|
||||||
|
|
||||||
<div v-if="group.status === 'FORMING'" class="mb-4">
|
<div v-if="group.status === 'FORMING'" class="mb-4">
|
||||||
<p class="text-gray-500 mb-2">还差 <span class="font-bold text-lg" style="color: #171715">{{ group.requiredMembers - group.currentMembers }}</span> 人成团</p>
|
<p class="text-gray-500 mb-2">还差 <span class="font-bold text-lg" style="color: #171715">{{
|
||||||
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroup" />
|
group.requiredMembers - group.currentMembers
|
||||||
|
}}</span> 人成团</p>
|
||||||
|
<CountDown :end-time="new Date(group.expireTime).getTime()" @finish="loadGroup"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="group.status === 'SUCCESS'" class="mb-4">
|
<div v-else-if="group.status === 'SUCCESS'" class="mb-4">
|
||||||
<el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
|
<el-icon :size="48" color="#67c23a">
|
||||||
|
<CircleCheckFilled/>
|
||||||
|
</el-icon>
|
||||||
<p class="text-green-600 mt-2 font-semibold">拼团成功!</p>
|
<p class="text-green-600 mt-2 font-semibold">拼团成功!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mb-4">
|
<div v-else class="mb-4">
|
||||||
<el-icon :size="48" color="#909399"><CircleCloseFilled /></el-icon>
|
<el-icon :size="48" color="#909399">
|
||||||
|
<CircleCloseFilled/>
|
||||||
|
</el-icon>
|
||||||
<p class="text-gray-500 mt-2">拼团未成功</p>
|
<p class="text-gray-500 mt-2">拼团未成功</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,26 +50,32 @@
|
|||||||
<div class="members-section mb-8">
|
<div class="members-section mb-8">
|
||||||
<h3 class="text-lg font-bold mb-4">团成员 ({{ group.currentMembers }}/{{ group.requiredMembers }})</h3>
|
<h3 class="text-lg font-bold mb-4">团成员 ({{ group.currentMembers }}/{{ group.requiredMembers }})</h3>
|
||||||
<GroupMemberList
|
<GroupMemberList
|
||||||
:members="group.members"
|
:leader-user-id="group.leaderUserId"
|
||||||
:required-members="group.requiredMembers"
|
:members="group.members"
|
||||||
:leader-user-id="group.leaderUserId"
|
:required-members="group.requiredMembers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="flex gap-3 justify-center">
|
<div class="flex gap-3 justify-center">
|
||||||
<el-button v-if="group.status === 'FORMING' && !isInGroup" type="primary" size="large" @click="handleJoin" :loading="joining">
|
<el-button v-if="group.status === 'FORMING' && !isInGroup" :loading="joining" size="large" type="primary"
|
||||||
<el-icon class="mr-1"><Connection /></el-icon>
|
@click="handleJoin">
|
||||||
|
<el-icon class="mr-1">
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
加入拼团
|
加入拼团
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="group.status === 'FORMING' && isInGroup" type="danger" size="large" @click="handleCancel" :loading="cancelling">
|
<el-button v-if="group.status === 'FORMING' && isInGroup" :loading="cancelling" size="large" type="danger"
|
||||||
|
@click="handleCancel">
|
||||||
退出团组
|
退出团组
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="large" @click="$router.push(`/groupbuying/${group.groupBuyingId}`)">
|
<el-button size="large" @click="$router.push(`/groupbuying/${group.groupBuyingId}`)">
|
||||||
查看活动
|
查看活动
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="group.status === 'FORMING'" size="large" @click="handleShare">
|
<el-button v-if="group.status === 'FORMING'" size="large" @click="handleShare">
|
||||||
<el-icon class="mr-1"><Share /></el-icon>
|
<el-icon class="mr-1">
|
||||||
|
<Share/>
|
||||||
|
</el-icon>
|
||||||
邀请好友
|
邀请好友
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,14 +85,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { Loading } from '@element-plus/icons-vue'
|
import {Loading} from '@element-plus/icons-vue'
|
||||||
import type { GroupBuyingGroup } from '@/types/api'
|
import type {GroupBuyingGroup} from '@/types/api'
|
||||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
import {groupbuyingApi} from '@/api/modules/groupbuying'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import GroupMemberList from '@/components/business/GroupMemberList.vue'
|
import GroupMemberList from '@/components/business/GroupMemberList.vue'
|
||||||
import CountDown from '@/components/business/CountDown.vue'
|
import CountDown from '@/components/business/CountDown.vue'
|
||||||
|
|
||||||
@@ -93,10 +108,14 @@ const groupId = computed(() => Number(route.params.id))
|
|||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
switch (group.value?.status) {
|
switch (group.value?.status) {
|
||||||
case 'FORMING': return 'warning'
|
case 'FORMING':
|
||||||
case 'SUCCESS': return 'success'
|
return 'warning'
|
||||||
case 'FAILED': return 'info'
|
case 'SUCCESS':
|
||||||
default: return 'info'
|
return 'success'
|
||||||
|
case 'FAILED':
|
||||||
|
return 'info'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<!-- 页头 -->
|
<!-- 页头 -->
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<el-icon :size="28" class="mr-2"><Connection /></el-icon>
|
<el-icon :size="28" class="mr-2">
|
||||||
|
<Connection/>
|
||||||
|
</el-icon>
|
||||||
<h1 class="text-2xl font-bold">拼团活动</h1>
|
<h1 class="text-2xl font-bold">拼团活动</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
<el-radio-button label="ENDED">已结束</el-radio-button>
|
<el-radio-button label="ENDED">已结束</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
|
||||||
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
<el-button :icon="Refresh" :loading="loading" @click="loadList">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
@@ -41,46 +43,48 @@
|
|||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="loading" class="text-center py-20">
|
<div v-if="loading" class="text-center py-20">
|
||||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
<el-icon :size="32" class="is-loading">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div v-else-if="list.length === 0" class="text-center py-20">
|
<div v-else-if="list.length === 0" class="text-center py-20">
|
||||||
<el-empty description="暂无拼团活动" />
|
<el-empty description="暂无拼团活动"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 活动网格 -->
|
<!-- 活动网格 -->
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<GroupBuyingCard
|
<GroupBuyingCard
|
||||||
v-for="item in list"
|
v-for="item in list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:data="item"
|
:data="item"
|
||||||
@join="handleJoin"
|
@join="handleJoin"
|
||||||
@refresh="loadList"
|
@refresh="loadList"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div v-if="totalElements > 0" class="flex justify-center mt-8">
|
<div v-if="totalElements > 0" class="flex justify-center mt-8">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
:current-page="filters.page + 1"
|
:current-page="filters.page + 1"
|
||||||
:page-size="filters.size"
|
:page-size="filters.size"
|
||||||
:total="totalElements"
|
:total="totalElements"
|
||||||
layout="prev, pager, next"
|
layout="prev, pager, next"
|
||||||
@current-change="(p: number) => { filters.page = p - 1; loadList() }"
|
@current-change="(p: number) => { filters.page = p - 1; loadList() }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import {ref, reactive, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
import {Refresh, Loading} from '@element-plus/icons-vue'
|
||||||
import type { GroupBuying, GroupBuyingStatistics } from '@/types/api'
|
import type {GroupBuying, GroupBuyingStatistics} from '@/types/api'
|
||||||
import { groupbuyingApi } from '@/api/modules/groupbuying'
|
import {groupbuyingApi} from '@/api/modules/groupbuying'
|
||||||
import GroupBuyingCard from '@/components/business/GroupBuyingCard.vue'
|
import GroupBuyingCard from '@/components/business/GroupBuyingCard.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -130,7 +134,7 @@ const handleJoin = (id: number) => {
|
|||||||
onMounted(loadList)
|
onMounted(loadList)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@apply bg-white rounded-xl p-4 text-center;
|
@apply bg-white rounded-xl p-4 text-center;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<!-- 轮播图 -->
|
<!-- 轮播图 -->
|
||||||
<el-carousel height="400px" :interval="5000" arrow="hover">
|
<el-carousel :interval="5000" arrow="hover" height="400px">
|
||||||
<el-carousel-item v-for="item in banners" :key="item.id">
|
<el-carousel-item v-for="item in banners" :key="item.id">
|
||||||
<div class="banner-content" :style="{ background: item.bgColor }">
|
<div :style="{ background: item.bgColor }" class="banner-content">
|
||||||
<div class="container mx-auto px-4 h-full">
|
<div class="container mx-auto px-4 h-full">
|
||||||
<div class="flex items-center h-full">
|
<div class="flex items-center h-full">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<h1 class="banner-title text-4xl font-bold mb-4">
|
<h1 class="banner-title text-4xl font-bold mb-4">
|
||||||
<el-icon :size="40"><Lightning /></el-icon>
|
<el-icon :size="40">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="banner-subtitle text-xl mb-6">{{ item.subtitle }}</p>
|
<p class="banner-subtitle text-xl mb-6">{{ item.subtitle }}</p>
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 text-center">
|
<div class="w-1/2 text-center">
|
||||||
<el-icon :size="200" class="banner-illustration">
|
<el-icon :size="200" class="banner-illustration">
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon"/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,34 +33,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-carousel-item>
|
</el-carousel-item>
|
||||||
</el-carousel>
|
</el-carousel>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<!-- 商品分类 -->
|
<!-- 商品分类 -->
|
||||||
<section class="mb-12">
|
<section class="mb-12">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold flex items-center">
|
<h2 class="text-2xl font-bold flex items-center">
|
||||||
<el-icon class="section-icon mr-2"><Grid /></el-icon>
|
<el-icon class="section-icon mr-2">
|
||||||
|
<Grid/>
|
||||||
|
</el-icon>
|
||||||
商品分类
|
商品分类
|
||||||
</h2>
|
</h2>
|
||||||
<el-button text @click="router.push('/products')">
|
<el-button text @click="router.push('/products')">
|
||||||
全部商品
|
全部商品
|
||||||
<el-icon class="ml-1"><ArrowRight /></el-icon>
|
<el-icon class="ml-1">
|
||||||
|
<ArrowRight/>
|
||||||
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingCategories" class="text-center py-8">
|
<div v-if="loadingCategories" class="text-center py-8">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="cat in categoryList"
|
v-for="cat in categoryList"
|
||||||
:key="cat.name"
|
:key="cat.name"
|
||||||
class="category-card cursor-pointer"
|
class="category-card cursor-pointer"
|
||||||
@click="router.push(`/products?category=${encodeURIComponent(cat.name)}`)"
|
@click="router.push(`/products?category=${encodeURIComponent(cat.name)}`)"
|
||||||
>
|
>
|
||||||
<el-icon :size="32" class="category-icon mb-2">
|
<el-icon :size="32" class="category-icon mb-2">
|
||||||
<component :is="cat.icon" />
|
<component :is="cat.icon"/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span class="text-sm font-medium">{{ cat.name }}</span>
|
<span class="text-sm font-medium">{{ cat.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,87 +77,107 @@
|
|||||||
<section class="mb-12">
|
<section class="mb-12">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold flex items-center">
|
<h2 class="text-2xl font-bold flex items-center">
|
||||||
<el-icon class="section-icon mr-2"><Lightning /></el-icon>
|
<el-icon class="section-icon mr-2">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
限时活动
|
限时活动
|
||||||
</h2>
|
</h2>
|
||||||
<el-button text @click="router.push('/flashsale')">
|
<el-button text @click="router.push('/flashsale')">
|
||||||
查看全部
|
查看全部
|
||||||
<el-icon class="ml-1"><ArrowRight /></el-icon>
|
<el-icon class="ml-1">
|
||||||
|
<ArrowRight/>
|
||||||
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingFlashSales" class="text-center py-8">
|
<div v-if="loadingFlashSales" class="text-center py-8">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeFlashSales.length === 0" class="text-center py-8">
|
<div v-else-if="activeFlashSales.length === 0" class="text-center py-8">
|
||||||
<el-empty description="暂无进行中的限时活动"/>
|
<el-empty description="暂无进行中的限时活动"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<FlashSaleCard
|
<FlashSaleCard
|
||||||
v-for="item in activeFlashSales"
|
v-for="item in activeFlashSales"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:data="item"
|
:data="item"
|
||||||
@participate="handleParticipate"
|
@participate="handleParticipate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 热门商品 -->
|
<!-- 热门商品 -->
|
||||||
<section class="mb-12">
|
<section class="mb-12">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold flex items-center">
|
<h2 class="text-2xl font-bold flex items-center">
|
||||||
<el-icon class="section-icon mr-2"><Star /></el-icon>
|
<el-icon class="section-icon mr-2">
|
||||||
|
<Star/>
|
||||||
|
</el-icon>
|
||||||
热门商品
|
热门商品
|
||||||
</h2>
|
</h2>
|
||||||
<el-button text @click="router.push('/products')">
|
<el-button text @click="router.push('/products')">
|
||||||
查看全部
|
查看全部
|
||||||
<el-icon class="ml-1"><ArrowRight /></el-icon>
|
<el-icon class="ml-1">
|
||||||
|
<ArrowRight/>
|
||||||
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingProducts" class="text-center py-8">
|
<div v-if="loadingProducts" class="text-center py-8">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="hotProducts.length === 0" class="text-center py-8">
|
<div v-else-if="hotProducts.length === 0" class="text-center py-8">
|
||||||
<el-empty description="暂无热门商品" />
|
<el-empty description="暂无热门商品"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<ProductCard
|
<ProductCard
|
||||||
v-for="item in hotProducts"
|
v-for="item in hotProducts"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:data="item"
|
:data="item"
|
||||||
@add-to-cart="handleAddToCart"
|
@add-to-cart="handleAddToCart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 系统特性 -->
|
<!-- 系统特性 -->
|
||||||
<section class="mb-12">
|
<section class="mb-12">
|
||||||
<h2 class="text-2xl font-bold text-center mb-8">系统特性</h2>
|
<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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="feature-icon mb-4"><Lightning /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4">
|
||||||
|
<Lightning/>
|
||||||
|
</el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">限时优惠</h3>
|
<h3 class="text-lg font-semibold mb-2">限时优惠</h3>
|
||||||
<p class="text-gray-600">社区生鲜团购系统,支持热门商品集中促销</p>
|
<p class="text-gray-600">社区生鲜团购系统,支持热门商品集中促销</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="feature-icon mb-4"><Lock /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4">
|
||||||
|
<Lock/>
|
||||||
|
</el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">防超卖</h3>
|
<h3 class="text-lg font-semibold mb-2">防超卖</h3>
|
||||||
<p class="text-gray-600">分布式锁机制,确保库存数据一致性</p>
|
<p class="text-gray-600">分布式锁机制,确保库存数据一致性</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="feature-icon mb-4"><Coin /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4">
|
||||||
|
<Coin/>
|
||||||
|
</el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">Redis缓存</h3>
|
<h3 class="text-lg font-semibold mb-2">Redis缓存</h3>
|
||||||
<p class="text-gray-600">五种数据类型应用,毫秒级响应</p>
|
<p class="text-gray-600">五种数据类型应用,毫秒级响应</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<el-icon :size="40" class="feature-icon mb-4"><Odometer /></el-icon>
|
<el-icon :size="40" class="feature-icon mb-4">
|
||||||
|
<Odometer/>
|
||||||
|
</el-icon>
|
||||||
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
|
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
|
||||||
<p class="text-gray-600">多种限流策略,防止恶意刷单</p>
|
<p class="text-gray-600">多种限流策略,防止恶意刷单</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,17 +187,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import {ref, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
|
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
|
||||||
import ProductCard from '@/components/business/ProductCard.vue'
|
import ProductCard from '@/components/business/ProductCard.vue'
|
||||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
import {flashsaleApi} from '@/api/modules/flashsale'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { FlashSale, Product } from '@/types/api'
|
import type {FlashSale, Product} from '@/types/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const cartStore = useCartStore()
|
const cartStore = useCartStore()
|
||||||
@@ -295,7 +323,7 @@ const handleAddToCart = async (productId: number) => {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await cartStore.addToCart(productId)
|
await cartStore.addToCart(productId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +334,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.home-page {
|
.home-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="order-detail-page">
|
<div class="order-detail-page">
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item :to="{ path: '/orders' }">我的订单</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/orders' }">我的订单</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>订单详情</el-breadcrumb-item>
|
<el-breadcrumb-item>订单详情</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!order" class="text-center py-12">
|
<div v-else-if="!order" class="text-center py-12">
|
||||||
<el-empty description="订单不存在" />
|
<el-empty description="订单不存在"/>
|
||||||
<el-button type="primary" @click="router.push('/orders')">返回订单列表</el-button>
|
<el-button type="primary" @click="router.push('/orders')">返回订单列表</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,10 +27,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-steps :active="getActiveStep()" finish-status="success">
|
<el-steps :active="getActiveStep()" finish-status="success">
|
||||||
<el-step title="提交订单" :description="formatTime(order.createdAt)" />
|
<el-step :description="formatTime(order.createdAt)" title="提交订单"/>
|
||||||
<el-step title="支付订单" :description="order.paidAt ? formatTime(order.paidAt) : ''" />
|
<el-step :description="order.paidAt ? formatTime(order.paidAt) : ''" title="支付订单"/>
|
||||||
<el-step title="商家发货" :description="order.shippedAt ? formatTime(order.shippedAt) : ''" />
|
<el-step :description="order.shippedAt ? formatTime(order.shippedAt) : ''" title="商家发货"/>
|
||||||
<el-step title="确认收货" :description="order.completedAt ? formatTime(order.completedAt) : ''" />
|
<el-step :description="order.completedAt ? formatTime(order.completedAt) : ''" title="确认收货"/>
|
||||||
</el-steps>
|
</el-steps>
|
||||||
|
|
||||||
<div class="mt-6 flex gap-2">
|
<div class="mt-6 flex gap-2">
|
||||||
@@ -47,8 +49,12 @@
|
|||||||
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="order.status === 'REFUNDING'">
|
<template v-else-if="order.status === 'REFUNDING'">
|
||||||
<el-button v-if="orderReturn && orderReturn.status === 'APPROVED'" type="primary" @click="trackingDialogVisible = true">填写物流单号</el-button>
|
<el-button v-if="orderReturn && orderReturn.status === 'APPROVED'" type="primary"
|
||||||
<el-button v-if="orderReturn && (orderReturn.status === 'PENDING' || orderReturn.status === 'APPROVED')" @click="handleCancelReturn">取消退货</el-button>
|
@click="trackingDialogVisible = true">填写物流单号
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="orderReturn && (orderReturn.status === 'PENDING' || orderReturn.status === 'APPROVED')"
|
||||||
|
@click="handleCancelReturn">取消退货
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="order.status === 'REFUNDED'">
|
<template v-else-if="order.status === 'REFUNDED'">
|
||||||
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
||||||
@@ -62,8 +68,12 @@
|
|||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">收货信息</h3>
|
<h3 class="text-lg font-semibold mb-4">收货信息</h3>
|
||||||
<div v-if="order.address" class="text-sm space-y-2">
|
<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.name }} {{
|
||||||
<div><span class="text-gray-500">收货地址:</span><span>{{ order.address.province }} {{ order.address.city }} {{ order.address.district }} {{ order.address.address }}</span></div>
|
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>
|
||||||
<div v-else class="text-gray-500">暂无收货信息</div>
|
<div v-else class="text-gray-500">暂无收货信息</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,95 +82,120 @@
|
|||||||
<h3 class="text-lg font-semibold mb-4">商品信息</h3>
|
<h3 class="text-lg font-semibold mb-4">商品信息</h3>
|
||||||
<div class="space-y-4">
|
<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">
|
<div v-for="item in order.items" :key="item.id" class="flex gap-4 pb-4 border-b last:border-0">
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-24 h-24 rounded" img-class="w-24 h-24 object-cover rounded" />
|
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-24 h-24 object-cover rounded"
|
||||||
|
wrapper-class="w-24 h-24 rounded"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold mb-2">{{ item.productName }}</h4>
|
<h4 class="font-semibold mb-2">{{ item.productName }}</h4>
|
||||||
<div class="text-sm text-gray-500">单价:¥{{ item.price }} × {{ item.quantity }}</div>
|
<div class="text-sm text-gray-500">单价:¥{{ item.price }} × {{ item.quantity }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right"><div class="font-semibold text-lg">¥{{ item.subtotal }}</div></div>
|
<div class="text-right">
|
||||||
|
<div class="font-semibold text-lg">¥{{ item.subtotal }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t pt-4 mt-4 space-y-2">
|
<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>¥{{ 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>¥0.00</span></div>
|
||||||
<div class="flex justify-between text-sm"><span class="text-gray-500">优惠</span><span class="text-red-500">-¥0.00</span></div>
|
<div class="flex justify-between text-sm"><span class="text-gray-500">优惠</span><span class="text-red-500">-¥0.00</span>
|
||||||
<div class="flex justify-between text-lg font-semibold pt-2 border-t"><span>实付金额</span><span class="text-red-500">¥{{ order.paymentAmount }}</span></div>
|
</div>
|
||||||
|
<div class="flex justify-between text-lg font-semibold pt-2 border-t"><span>实付金额</span><span
|
||||||
|
class="text-red-500">¥{{ order.paymentAmount }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">订单信息</h3>
|
<h3 class="text-lg font-semibold mb-4">订单信息</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
<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>{{ order.orderNo }}</span>
|
||||||
|
<el-button size="small" text type="primary" @click="copyOrderNo">复制</el-button>
|
||||||
|
</div>
|
||||||
<div><span class="text-gray-500">创建时间:</span><span>{{ formatTime(order.createdAt) }}</span></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.paidAt"><span class="text-gray-500">付款时间:</span><span>{{
|
||||||
<div v-if="order.paymentMethod"><span class="text-gray-500">支付方式:</span><span>{{ getPaymentMethodText(order.paymentMethod) }}</span></div>
|
formatTime(order.paidAt)
|
||||||
<div v-if="order.shippedAt"><span class="text-gray-500">发货时间:</span><span>{{ formatTime(order.shippedAt) }}</span></div>
|
}}</span></div>
|
||||||
<div v-if="order.completedAt"><span class="text-gray-500">完成时间:</span><span>{{ formatTime(order.completedAt) }}</span></div>
|
<div v-if="order.paymentMethod"><span
|
||||||
<div v-if="order.remark" class="md:col-span-2"><span class="text-gray-500">订单备注:</span><span>{{ order.remark }}</span></div>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 退货信息 -->
|
<!-- 退货信息 -->
|
||||||
<div v-if="orderReturn && (order.status === 'REFUNDING' || order.status === 'REFUNDED')" class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
<div v-if="orderReturn && (order.status === 'REFUNDING' || order.status === 'REFUNDED')"
|
||||||
<h3 class="text-lg font-semibold mb-4">退货信息</h3>
|
class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
<h3 class="text-lg font-semibold mb-4">退货信息</h3>
|
||||||
<div><span class="text-gray-500">退货单号:</span><span>{{ orderReturn.returnNo }}</span></div>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
<div><span class="text-gray-500">退货状态:</span><el-tag :type="getReturnStatusType(orderReturn.status)" size="small">{{ orderReturn.statusText }}</el-tag></div>
|
<div><span class="text-gray-500">退货单号:</span><span>{{ orderReturn.returnNo }}</span></div>
|
||||||
<div><span class="text-gray-500">退款金额:</span><span class="text-red-500 font-semibold">¥{{ orderReturn.refundAmount }}</span></div>
|
<div><span class="text-gray-500">退货状态:</span>
|
||||||
<div><span class="text-gray-500">退货原因:</span><span>{{ orderReturn.reason }}</span></div>
|
<el-tag :type="getReturnStatusType(orderReturn.status)" size="small">{{ orderReturn.statusText }}</el-tag>
|
||||||
<div v-if="orderReturn.description" class="md:col-span-2"><span class="text-gray-500">详细描述:</span><span>{{ orderReturn.description }}</span></div>
|
|
||||||
<div v-if="orderReturn.rejectReason" class="md:col-span-2"><span class="text-gray-500">拒绝原因:</span><span class="text-red-500">{{ orderReturn.rejectReason }}</span></div>
|
|
||||||
<div v-if="orderReturn.returnTracking"><span class="text-gray-500">物流单号:</span><span>{{ orderReturn.returnTracking }}</span></div>
|
|
||||||
<div v-if="orderReturn.adminRemark" class="md:col-span-2"><span class="text-gray-500">管理员备注:</span><span>{{ orderReturn.adminRemark }}</span></div>
|
|
||||||
<div><span class="text-gray-500">申请时间:</span><span>{{ formatTime(orderReturn.createdAt) }}</span></div>
|
|
||||||
<div v-if="orderReturn.completedAt"><span class="text-gray-500">完成时间:</span><span>{{ formatTime(orderReturn.completedAt) }}</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div><span class="text-gray-500">退款金额:</span><span
|
||||||
|
class="text-red-500 font-semibold">¥{{ orderReturn.refundAmount }}</span></div>
|
||||||
|
<div><span class="text-gray-500">退货原因:</span><span>{{ orderReturn.reason }}</span></div>
|
||||||
|
<div v-if="orderReturn.description" class="md:col-span-2"><span
|
||||||
|
class="text-gray-500">详细描述:</span><span>{{ orderReturn.description }}</span></div>
|
||||||
|
<div v-if="orderReturn.rejectReason" class="md:col-span-2"><span class="text-gray-500">拒绝原因:</span><span
|
||||||
|
class="text-red-500">{{ orderReturn.rejectReason }}</span></div>
|
||||||
|
<div v-if="orderReturn.returnTracking"><span
|
||||||
|
class="text-gray-500">物流单号:</span><span>{{ orderReturn.returnTracking }}</span></div>
|
||||||
|
<div v-if="orderReturn.adminRemark" class="md:col-span-2"><span class="text-gray-500">管理员备注:</span><span>{{
|
||||||
|
orderReturn.adminRemark
|
||||||
|
}}</span></div>
|
||||||
|
<div><span class="text-gray-500">申请时间:</span><span>{{ formatTime(orderReturn.createdAt) }}</span></div>
|
||||||
|
<div v-if="orderReturn.completedAt"><span
|
||||||
|
class="text-gray-500">完成时间:</span><span>{{ formatTime(orderReturn.completedAt) }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ReviewDialog
|
<ReviewDialog
|
||||||
v-if="order"
|
v-if="order"
|
||||||
v-model:visible="reviewDialogVisible"
|
v-model:visible="reviewDialogVisible"
|
||||||
:order-id="order.id"
|
:order-id="order.id"
|
||||||
:order-items="order.items"
|
:order-items="order.items"
|
||||||
@success="checkAllReviewed"
|
@success="checkAllReviewed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReturnDialog
|
<ReturnDialog
|
||||||
v-if="order"
|
v-if="order"
|
||||||
v-model:visible="returnDialogVisible"
|
v-model:visible="returnDialogVisible"
|
||||||
:order-id="order.id"
|
:order-id="order.id"
|
||||||
:refund-amount="order.paymentAmount"
|
:refund-amount="order.paymentAmount"
|
||||||
@success="onReturnSuccess"
|
@success="onReturnSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReturnTrackingDialog
|
<ReturnTrackingDialog
|
||||||
v-if="orderReturn"
|
v-if="orderReturn"
|
||||||
v-model:visible="trackingDialogVisible"
|
v-model:visible="trackingDialogVisible"
|
||||||
:return-id="orderReturn.id"
|
:return-id="orderReturn.id"
|
||||||
@success="onReturnSuccess"
|
@success="onReturnSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import {ref, onMounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { orderApi } from '@/api/modules/order'
|
import {orderApi} from '@/api/modules/order'
|
||||||
import { reviewApi } from '@/api/modules/review'
|
import {reviewApi} from '@/api/modules/review'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import type { Order } from '@/types/api'
|
import type {Order} from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
||||||
import ReturnDialog from '@/components/business/ReturnDialog.vue'
|
import ReturnDialog from '@/components/business/ReturnDialog.vue'
|
||||||
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
|
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
|
||||||
import { returnApi } from '@/api/modules/return'
|
import {returnApi} from '@/api/modules/return'
|
||||||
import type { OrderReturn } from '@/types/api'
|
import type {OrderReturn} from '@/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -175,18 +210,45 @@ const trackingDialogVisible = ref(false)
|
|||||||
const orderReturn = ref<OrderReturn | null>(null)
|
const orderReturn = ref<OrderReturn | null>(null)
|
||||||
|
|
||||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||||
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDING: 'warning', REFUNDED: 'danger' }[status] || 'info')
|
const getStatusType = (status: string) => ({
|
||||||
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退货中', REFUNDED: '已退货' }[status] || status)
|
PENDING: 'warning',
|
||||||
const getPaymentMethodText = (method: string) => ({ ONLINE: '在线支付', ALIPAY: '支付宝', WECHAT: '微信支付', CASH: '货到付款', default: '默认支付' }[method] || method)
|
PAID: 'primary',
|
||||||
|
SHIPPED: 'primary',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
CANCELLED: 'info',
|
||||||
|
REFUNDING: 'warning',
|
||||||
|
REFUNDED: 'danger'
|
||||||
|
}[status] || 'info')
|
||||||
|
const getStatusText = (status: string) => ({
|
||||||
|
PENDING: '待付款',
|
||||||
|
PAID: '待发货',
|
||||||
|
SHIPPED: '待收货',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
REFUNDING: '退货中',
|
||||||
|
REFUNDED: '已退货'
|
||||||
|
}[status] || status)
|
||||||
|
const getPaymentMethodText = (method: string) => ({
|
||||||
|
ONLINE: '在线支付',
|
||||||
|
ALIPAY: '支付宝',
|
||||||
|
WECHAT: '微信支付',
|
||||||
|
CASH: '货到付款',
|
||||||
|
default: '默认支付'
|
||||||
|
}[method] || method)
|
||||||
|
|
||||||
const getActiveStep = () => {
|
const getActiveStep = () => {
|
||||||
if (!order.value) return 0
|
if (!order.value) return 0
|
||||||
switch (order.value.status) {
|
switch (order.value.status) {
|
||||||
case 'PENDING': return 1
|
case 'PENDING':
|
||||||
case 'PAID': return 2
|
return 1
|
||||||
case 'SHIPPED': return 3
|
case 'PAID':
|
||||||
case 'COMPLETED': return 4
|
return 2
|
||||||
default: return 0
|
case 'SHIPPED':
|
||||||
|
return 3
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,27 +273,57 @@ const copyOrderNo = () => {
|
|||||||
|
|
||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
await ElMessageBox.confirm(`订单金额:¥${order.value.paymentAmount},确认付款?`, '付款确认', { confirmButtonText: '确认付款', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm(`订单金额:¥${order.value.paymentAmount},确认付款?`, '付款确认', {
|
||||||
try { await orderApi.pay(order.value.id, 'ONLINE'); ElMessage.success('付款成功'); loadOrderDetail() } catch (error) { console.error('付款失败:', error) }
|
confirmButtonText: '确认付款',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.pay(order.value.id, 'ONLINE');
|
||||||
|
ElMessage.success('付款成功');
|
||||||
|
loadOrderDetail()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('付款失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
|
||||||
try { await orderApi.cancel(order.value.id); ElMessage.success('订单已取消'); loadOrderDetail() } catch (error) { console.error('取消订单失败:', error) }
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.cancel(order.value.id);
|
||||||
|
ElMessage.success('订单已取消');
|
||||||
|
loadOrderDetail()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消订单失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
await ElMessageBox.confirm('确定已收到商品?', '确认收货', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
|
||||||
try { await orderApi.confirm(order.value.id); ElMessage.success('已确认收货'); loadOrderDetail() } catch (error) { console.error('确认收货失败:', error) }
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.confirm(order.value.id);
|
||||||
|
ElMessage.success('已确认收货');
|
||||||
|
loadOrderDetail()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('确认收货失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkAllReviewed = async () => {
|
const checkAllReviewed = async () => {
|
||||||
if (!order.value || order.value.status !== 'COMPLETED') return
|
if (!order.value || order.value.status !== 'COMPLETED') return
|
||||||
try {
|
try {
|
||||||
const checks = await Promise.all(
|
const checks = await Promise.all(
|
||||||
order.value.items.map(item => reviewApi.checkReview(order.value!.id, item.productId).catch(() => null))
|
order.value.items.map(item => reviewApi.checkReview(order.value!.id, item.productId).catch(() => null))
|
||||||
)
|
)
|
||||||
allReviewed.value = checks.every(res => res?.success && res.data.reviewed)
|
allReviewed.value = checks.every(res => res?.success && res.data.reviewed)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -239,7 +331,14 @@ const checkAllReviewed = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReturnStatusType = (status: string) => ({ PENDING: 'warning', APPROVED: 'primary', RETURNING: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info' }[status] || 'info')
|
const getReturnStatusType = (status: string) => ({
|
||||||
|
PENDING: 'warning',
|
||||||
|
APPROVED: 'primary',
|
||||||
|
RETURNING: 'primary',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
REJECTED: 'danger',
|
||||||
|
CANCELLED: 'info'
|
||||||
|
}[status] || 'info')
|
||||||
|
|
||||||
const loadReturnInfo = async () => {
|
const loadReturnInfo = async () => {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
@@ -261,7 +360,11 @@ const onReturnSuccess = () => {
|
|||||||
|
|
||||||
const handleCancelReturn = async () => {
|
const handleCancelReturn = async () => {
|
||||||
if (!orderReturn.value) return
|
if (!orderReturn.value) return
|
||||||
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
await returnApi.cancel(orderReturn.value.id)
|
await returnApi.cancel(orderReturn.value.id)
|
||||||
ElMessage.success('退货申请已取消')
|
ElMessage.success('退货申请已取消')
|
||||||
@@ -282,8 +385,18 @@ const handleRebuy = async () => {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', {
|
||||||
try { await orderApi.delete(order.value.id); ElMessage.success('订单已删除'); router.push('/orders') } catch (error) { console.error('删除订单失败:', error) }
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.delete(order.value.id);
|
||||||
|
ElMessage.success('订单已删除');
|
||||||
|
router.push('/orders')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除订单失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -292,7 +405,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.order-detail-page {
|
.order-detail-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -3,14 +3,20 @@
|
|||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold flex items-center">
|
<h1 class="text-3xl font-bold flex items-center">
|
||||||
<el-icon class="text-blue-500 mr-2"><List /></el-icon>
|
<el-icon class="text-blue-500 mr-2">
|
||||||
|
<List/>
|
||||||
|
</el-icon>
|
||||||
我的订单
|
我的订单
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
<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)">
|
<div v-for="stat in orderStats" :key="stat.key"
|
||||||
<el-icon :size="24" :class="stat.color" class="mb-2"><component :is="stat.icon" /></el-icon>
|
class="bg-white rounded-lg p-4 text-center cursor-pointer hover:shadow-md transition-shadow"
|
||||||
|
@click="handleStatusFilter(stat.key)">
|
||||||
|
<el-icon :class="stat.color" :size="24" class="mb-2">
|
||||||
|
<component :is="stat.icon"/>
|
||||||
|
</el-icon>
|
||||||
<div class="text-2xl font-bold">{{ stat.count }}</div>
|
<div class="text-2xl font-bold">{{ stat.count }}</div>
|
||||||
<div class="text-sm text-gray-500">{{ stat.label }}</div>
|
<div class="text-sm text-gray-500">{{ stat.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,16 +35,21 @@
|
|||||||
<el-radio-button label="REFUNDED">已退货</el-radio-button>
|
<el-radio-button label="REFUNDED">已退货</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
|
||||||
<el-input v-model="filters.keyword" placeholder="搜索订单号或商品名称" style="width: 250px" clearable @keyup.enter="loadOrders">
|
<el-input v-model="filters.keyword" clearable placeholder="搜索订单号或商品名称" style="width: 250px"
|
||||||
|
@keyup.enter="loadOrders">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<el-icon class="cursor-pointer" @click="loadOrders"><Search /></el-icon>
|
<el-icon class="cursor-pointer" @click="loadOrders">
|
||||||
|
<Search/>
|
||||||
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,19 +68,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<el-tag v-if="order.orderType === 'FLASH_SALE'" size="small" type="danger">限时</el-tag>
|
<el-tag v-if="order.orderType === 'FLASH_SALE'" size="small" type="danger">限时</el-tag>
|
||||||
<el-tag v-else-if="order.orderType === 'GROUP_BUYING'" type="success" size="small">拼团</el-tag>
|
<el-tag v-else-if="order.orderType === 'GROUP_BUYING'" size="small" type="success">拼团</el-tag>
|
||||||
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
|
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div v-for="item in order.items" :key="item.id" class="flex gap-4 mb-4 last:mb-0">
|
<div v-for="item in order.items" :key="item.id" class="flex gap-4 mb-4 last:mb-0">
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-20 h-20 rounded" img-class="w-20 h-20 object-cover rounded" />
|
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-20 h-20 object-cover rounded"
|
||||||
|
wrapper-class="w-20 h-20 rounded"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold">{{ item.productName }}</h4>
|
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||||
<div class="text-sm text-gray-500 mt-1">¥{{ item.price }} × {{ item.quantity }}</div>
|
<div class="text-sm text-gray-500 mt-1">¥{{ item.price }} × {{ item.quantity }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right"><div class="font-semibold">¥{{ item.subtotal }}</div></div>
|
<div class="text-right">
|
||||||
|
<div class="font-semibold">¥{{ item.subtotal }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,32 +96,33 @@
|
|||||||
<el-button text type="primary" @click="handleViewDetail(order.id)">查看详情</el-button>
|
<el-button text type="primary" @click="handleViewDetail(order.id)">查看详情</el-button>
|
||||||
|
|
||||||
<template v-if="order.status === 'PENDING'">
|
<template v-if="order.status === 'PENDING'">
|
||||||
<el-button type="primary" size="small" @click="handlePay(order)">立即付款</el-button>
|
<el-button size="small" type="primary" @click="handlePay(order)">立即付款</el-button>
|
||||||
<el-button size="small" @click="handleCancel(order)">取消订单</el-button>
|
<el-button size="small" @click="handleCancel(order)">取消订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="order.status === 'SHIPPED'">
|
<template v-else-if="order.status === 'SHIPPED'">
|
||||||
<el-button type="primary" size="small" @click="handleConfirm(order)">确认收货</el-button>
|
<el-button size="small" type="primary" @click="handleConfirm(order)">确认收货</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="order.status === 'COMPLETED'">
|
<template v-else-if="order.status === 'COMPLETED'">
|
||||||
<el-button v-if="orderReviewStatus[order.id]" size="small" @click="openReviewDialog(order)">查看评价</el-button>
|
<el-button v-if="orderReviewStatus[order.id]" size="small" @click="openReviewDialog(order)">查看评价
|
||||||
<el-button v-else type="primary" size="small" @click="openReviewDialog(order)">评价</el-button>
|
</el-button>
|
||||||
<el-button type="warning" size="small" @click="openReturnDialog(order)">申请退货</el-button>
|
<el-button v-else size="small" type="primary" @click="openReviewDialog(order)">评价</el-button>
|
||||||
|
<el-button size="small" type="warning" @click="openReturnDialog(order)">申请退货</el-button>
|
||||||
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
|
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
|
||||||
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
<el-button size="small" text type="danger" @click="handleDelete(order)">删除订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="order.status === 'REFUNDING'">
|
<template v-else-if="order.status === 'REFUNDING'">
|
||||||
<el-button type="primary" size="small" @click="handleViewDetail(order.id)">查看退货进度</el-button>
|
<el-button size="small" type="primary" @click="handleViewDetail(order.id)">查看退货进度</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="order.status === 'REFUNDED'">
|
<template v-else-if="order.status === 'REFUNDED'">
|
||||||
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
<el-button size="small" text type="danger" @click="handleDelete(order)">删除订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="order.status === 'CANCELLED'">
|
<template v-else-if="order.status === 'CANCELLED'">
|
||||||
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
<el-button size="small" text type="danger" @click="handleDelete(order)">删除订单</el-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,36 +130,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="orders.length > 0" class="mt-8 flex justify-center">
|
<div v-if="orders.length > 0" class="mt-8 flex justify-center">
|
||||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="loadOrders" @current-change="loadOrders" />
|
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="pagination.total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper" @size-change="loadOrders"
|
||||||
|
@current-change="loadOrders"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReviewDialog
|
<ReviewDialog
|
||||||
v-if="currentReviewOrder"
|
v-if="currentReviewOrder"
|
||||||
v-model:visible="reviewDialogVisible"
|
v-model:visible="reviewDialogVisible"
|
||||||
:order-id="currentReviewOrder.id"
|
:order-id="currentReviewOrder.id"
|
||||||
:order-items="currentReviewOrder.items"
|
:order-items="currentReviewOrder.items"
|
||||||
@success="onReviewSuccess"
|
@success="onReviewSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReturnDialog
|
<ReturnDialog
|
||||||
v-if="currentReturnOrder"
|
v-if="currentReturnOrder"
|
||||||
v-model:visible="returnDialogVisible"
|
v-model:visible="returnDialogVisible"
|
||||||
:order-id="currentReturnOrder.id"
|
:order-id="currentReturnOrder.id"
|
||||||
:refund-amount="currentReturnOrder.paymentAmount || currentReturnOrder.totalAmount"
|
:refund-amount="currentReturnOrder.paymentAmount || currentReturnOrder.totalAmount"
|
||||||
@success="onReturnSuccess"
|
@success="onReturnSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import {ref, reactive, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { orderApi } from '@/api/modules/order'
|
import {orderApi} from '@/api/modules/order'
|
||||||
import { reviewApi } from '@/api/modules/review'
|
import {reviewApi} from '@/api/modules/review'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import type { Order } from '@/types/api'
|
import type {Order} from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
import ReviewDialog from '@/components/business/ReviewDialog.vue'
|
||||||
@@ -156,8 +174,8 @@ const cartStore = useCartStore()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const orders = ref<Order[]>([])
|
const orders = ref<Order[]>([])
|
||||||
|
|
||||||
const filters = reactive({ status: '', keyword: '' })
|
const filters = reactive({status: '', keyword: ''})
|
||||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
const pagination = reactive({page: 1, size: 10, total: 0})
|
||||||
const reviewDialogVisible = ref(false)
|
const reviewDialogVisible = ref(false)
|
||||||
const currentReviewOrder = ref<Order | null>(null)
|
const currentReviewOrder = ref<Order | null>(null)
|
||||||
const orderReviewStatus = ref<Record<number, boolean>>({})
|
const orderReviewStatus = ref<Record<number, boolean>>({})
|
||||||
@@ -165,29 +183,49 @@ const returnDialogVisible = ref(false)
|
|||||||
const currentReturnOrder = ref<Order | null>(null)
|
const currentReturnOrder = ref<Order | null>(null)
|
||||||
|
|
||||||
const orderStats = ref([
|
const orderStats = ref([
|
||||||
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
|
{key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500'},
|
||||||
{ key: 'PENDING', label: '待付款', count: 0, icon: 'Clock', color: 'text-orange-500' },
|
{key: 'PENDING', label: '待付款', count: 0, icon: 'Clock', color: 'text-orange-500'},
|
||||||
{ key: 'PAID', label: '待发货', count: 0, icon: 'Box', color: 'text-blue-500' },
|
{key: 'PAID', label: '待发货', count: 0, icon: 'Box', color: 'text-blue-500'},
|
||||||
{ key: 'SHIPPED', label: '待收货', count: 0, icon: 'Van', color: 'text-purple-500' },
|
{key: 'SHIPPED', label: '待收货', count: 0, icon: 'Van', color: 'text-purple-500'},
|
||||||
{ key: 'COMPLETED', label: '已完成', count: 0, icon: 'CircleCheck', color: 'text-green-500' },
|
{key: 'COMPLETED', label: '已完成', count: 0, icon: 'CircleCheck', color: 'text-green-500'},
|
||||||
{ key: 'CANCELLED', label: '已取消', count: 0, icon: 'CircleClose', color: 'text-gray-400' },
|
{key: 'CANCELLED', label: '已取消', count: 0, icon: 'CircleClose', color: 'text-gray-400'},
|
||||||
])
|
])
|
||||||
|
|
||||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
|
||||||
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDING: 'warning', REFUNDED: 'danger' }[status] || 'info')
|
const getStatusType = (status: string) => ({
|
||||||
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退货中', REFUNDED: '已退货' }[status] || status)
|
PENDING: 'warning',
|
||||||
|
PAID: 'primary',
|
||||||
|
SHIPPED: 'primary',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
CANCELLED: 'info',
|
||||||
|
REFUNDING: 'warning',
|
||||||
|
REFUNDED: 'danger'
|
||||||
|
}[status] || 'info')
|
||||||
|
const getStatusText = (status: string) => ({
|
||||||
|
PENDING: '待付款',
|
||||||
|
PAID: '待发货',
|
||||||
|
SHIPPED: '待收货',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
REFUNDING: '退货中',
|
||||||
|
REFUNDED: '已退货'
|
||||||
|
}[status] || status)
|
||||||
|
|
||||||
const loadOrders = async () => {
|
const loadOrders = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await orderApi.getList({ page: pagination.page - 1, size: pagination.size, status: filters.status || undefined })
|
const res = await orderApi.getList({
|
||||||
|
page: pagination.page - 1,
|
||||||
|
size: pagination.size,
|
||||||
|
status: filters.status || undefined
|
||||||
|
})
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const keyword = filters.keyword.trim().toLowerCase()
|
const keyword = filters.keyword.trim().toLowerCase()
|
||||||
const list = res.data.content
|
const list = res.data.content
|
||||||
orders.value = keyword
|
orders.value = keyword
|
||||||
? list.filter((order) => order.orderNo.toLowerCase().includes(keyword) || order.items.some((item) => item.productName.toLowerCase().includes(keyword)))
|
? list.filter((order) => order.orderNo.toLowerCase().includes(keyword) || order.items.some((item) => item.productName.toLowerCase().includes(keyword)))
|
||||||
: list
|
: list
|
||||||
pagination.total = res.data.totalElements
|
pagination.total = res.data.totalElements
|
||||||
checkOrdersReviewStatus(orders.value)
|
checkOrdersReviewStatus(orders.value)
|
||||||
}
|
}
|
||||||
@@ -212,37 +250,74 @@ const loadStatistics = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStatusFilter = (status: string) => { filters.status = status; pagination.page = 1; loadOrders() }
|
const handleStatusFilter = (status: string) => {
|
||||||
|
filters.status = status;
|
||||||
|
pagination.page = 1;
|
||||||
|
loadOrders()
|
||||||
|
}
|
||||||
const handleViewDetail = (orderId: number) => router.push(`/order/${orderId}`)
|
const handleViewDetail = (orderId: number) => router.push(`/order/${orderId}`)
|
||||||
|
|
||||||
const handlePay = async (order: Order) => {
|
const handlePay = async (order: Order) => {
|
||||||
await ElMessageBox.confirm(`订单金额:¥${order.paymentAmount || order.totalAmount},确认付款?`, '付款确认', { confirmButtonText: '确认付款', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm(`订单金额:¥${order.paymentAmount || order.totalAmount},确认付款?`, '付款确认', {
|
||||||
try { await orderApi.pay(order.id, 'ONLINE'); ElMessage.success('付款成功'); loadOrders(); loadStatistics() } catch (error) { console.error('付款失败:', error) }
|
confirmButtonText: '确认付款',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.pay(order.id, 'ONLINE');
|
||||||
|
ElMessage.success('付款成功');
|
||||||
|
loadOrders();
|
||||||
|
loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('付款失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = async (order: Order) => {
|
const handleCancel = async (order: Order) => {
|
||||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
|
||||||
try { await orderApi.cancel(order.id); ElMessage.success('订单已取消'); loadOrders(); loadStatistics() } catch (error) { console.error('取消订单失败:', error) }
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.cancel(order.id);
|
||||||
|
ElMessage.success('订单已取消');
|
||||||
|
loadOrders();
|
||||||
|
loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消订单失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirm = async (order: Order) => {
|
const handleConfirm = async (order: Order) => {
|
||||||
await ElMessageBox.confirm('确定已收到商品?', '确认收货', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
|
||||||
try { await orderApi.confirm(order.id); ElMessage.success('已确认收货'); loadOrders(); loadStatistics() } catch (error) { console.error('确认收货失败:', error) }
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.confirm(order.id);
|
||||||
|
ElMessage.success('已确认收货');
|
||||||
|
loadOrders();
|
||||||
|
loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('确认收货失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkOrdersReviewStatus = async (orderList: Order[]) => {
|
const checkOrdersReviewStatus = async (orderList: Order[]) => {
|
||||||
const completed = orderList.filter(o => o.status === 'COMPLETED')
|
const completed = orderList.filter(o => o.status === 'COMPLETED')
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
completed.map(async (order) => {
|
completed.map(async (order) => {
|
||||||
try {
|
try {
|
||||||
const checks = await Promise.all(
|
const checks = await Promise.all(
|
||||||
order.items.map(item => reviewApi.checkReview(order.id, item.productId).catch(() => null))
|
order.items.map(item => reviewApi.checkReview(order.id, item.productId).catch(() => null))
|
||||||
)
|
)
|
||||||
orderReviewStatus.value[order.id] = checks.every(res => res?.success && res.data.reviewed)
|
orderReviewStatus.value[order.id] = checks.every(res => res?.success && res.data.reviewed)
|
||||||
} catch {
|
} catch {
|
||||||
orderReviewStatus.value[order.id] = false
|
orderReviewStatus.value[order.id] = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,14 +350,28 @@ const handleRebuy = async (order: Order) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (order: Order) => {
|
const handleDelete = async (order: Order) => {
|
||||||
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', {
|
||||||
try { await orderApi.delete(order.id); ElMessage.success('订单已删除'); loadOrders(); loadStatistics() } catch (error) { console.error('删除订单失败:', error) }
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await orderApi.delete(order.id);
|
||||||
|
ElMessage.success('订单已删除');
|
||||||
|
loadOrders();
|
||||||
|
loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除订单失败:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadOrders(); loadStatistics() })
|
onMounted(() => {
|
||||||
|
loadOrders();
|
||||||
|
loadStatistics()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.orders-page {
|
.orders-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2,66 +2,70 @@
|
|||||||
<div class="product-detail-page">
|
<div class="product-detail-page">
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>{{ product?.name || '商品详情' }}</el-breadcrumb-item>
|
<el-breadcrumb-item>{{ product?.name || '商品详情' }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!product" class="text-center py-12">
|
<div v-else-if="!product" class="text-center py-12">
|
||||||
<el-empty description="商品不存在" />
|
<el-empty description="商品不存在"/>
|
||||||
<el-button type="primary" @click="router.push('/products')">
|
<el-button type="primary" @click="router.push('/products')">
|
||||||
返回商品列表
|
返回商品列表
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
|
<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 class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
|
||||||
<!-- 左侧:商品图片 -->
|
<!-- 左侧:商品图片 -->
|
||||||
<div>
|
<div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<SafeImage :src="currentImage" :alt="product?.name || '商品图片'" wrapper-class="w-full h-[420px] rounded-2xl overflow-hidden bg-gray-100" img-class="w-full h-[420px] object-cover" />
|
<SafeImage :alt="product?.name || '商品图片'" :src="currentImage"
|
||||||
|
img-class="w-full h-[420px] object-cover"
|
||||||
|
wrapper-class="w-full h-[420px] rounded-2xl overflow-hidden bg-gray-100"/>
|
||||||
|
|
||||||
<!-- 商品状态 -->
|
<!-- 商品状态 -->
|
||||||
<div v-if="product.stock === 0" class="absolute top-4 right-4">
|
<div v-if="product.stock === 0" class="absolute top-4 right-4">
|
||||||
<el-tag type="info" size="large">已售罄</el-tag>
|
<el-tag size="large" type="info">已售罄</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片列表 -->
|
<!-- 图片列表 -->
|
||||||
<div v-if="product.images && product.images.length > 1" class="mt-4 flex gap-2 overflow-x-auto">
|
<div v-if="product.images && product.images.length > 1" class="mt-4 flex gap-2 overflow-x-auto">
|
||||||
<img
|
<img
|
||||||
v-for="(img, index) in product.images"
|
v-for="(img, index) in product.images"
|
||||||
:key="index"
|
:key="index"
|
||||||
:src="img"
|
:alt="`${product.name}-${index}`"
|
||||||
:alt="`${product.name}-${index}`"
|
:class="currentImage === img ? 'border-primary-500' : 'border-transparent'"
|
||||||
class="w-20 h-20 object-cover rounded cursor-pointer border-2"
|
:src="img"
|
||||||
:class="currentImage === img ? 'border-primary-500' : 'border-transparent'"
|
class="w-20 h-20 object-cover rounded cursor-pointer border-2"
|
||||||
@click="currentImage = img"
|
@click="currentImage = img"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:商品信息 -->
|
<!-- 右侧:商品信息 -->
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold mb-4">{{ product.name }}</h1>
|
<h1 class="text-3xl font-bold mb-4">{{ product.name }}</h1>
|
||||||
|
|
||||||
<!-- 商品描述 -->
|
<!-- 商品描述 -->
|
||||||
<p class="text-gray-600 mb-6">{{ product.description || '暂无描述' }}</p>
|
<p class="text-gray-600 mb-6">{{ product.description || '暂无描述' }}</p>
|
||||||
|
|
||||||
<!-- 价格 -->
|
<!-- 价格 -->
|
||||||
<div class="bg-gray-50 rounded-lg p-6 mb-6">
|
<div class="bg-gray-50 rounded-lg p-6 mb-6">
|
||||||
<div class="flex items-end mb-4">
|
<div class="flex items-end mb-4">
|
||||||
<span class="text-sm text-gray-500 mr-2">价格</span>
|
<span class="text-sm text-gray-500 mr-2">价格</span>
|
||||||
<span class="text-4xl font-bold text-red-500">¥{{ product.price }}</span>
|
<span class="text-4xl font-bold text-red-500">¥{{ product.price }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 销售信息 -->
|
<!-- 销售信息 -->
|
||||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -78,51 +82,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 购买数量 -->
|
<!-- 购买数量 -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm text-gray-600 mb-2">购买数量</label>
|
<label class="block text-sm text-gray-600 mb-2">购买数量</label>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="quantity"
|
v-model="quantity"
|
||||||
:min="1"
|
:disabled="product.stock === 0"
|
||||||
:max="product.stock"
|
:max="product.stock"
|
||||||
:disabled="product.stock === 0"
|
:min="1"
|
||||||
/>
|
/>
|
||||||
<span class="ml-3 text-sm text-gray-500">
|
<span class="ml-3 text-sm text-gray-500">
|
||||||
库存 {{ product.stock }} 件
|
库存 {{ product.stock }} 件
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="flex gap-4 mb-6">
|
<div class="flex gap-4 mb-6">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
:disabled="product.stock === 0"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="product.stock === 0"
|
type="primary"
|
||||||
@click="handleAddToCart"
|
@click="handleAddToCart"
|
||||||
>
|
>
|
||||||
<el-icon class="mr-2"><ShoppingCart /></el-icon>
|
<el-icon class="mr-2">
|
||||||
|
<ShoppingCart/>
|
||||||
|
</el-icon>
|
||||||
加入购物车
|
加入购物车
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
type="danger"
|
:disabled="product.stock === 0"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="product.stock === 0"
|
type="danger"
|
||||||
@click="handleBuyNow"
|
@click="handleBuyNow"
|
||||||
>
|
>
|
||||||
立即购买
|
立即购买
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<el-button size="large" @click="handleFavorite">
|
<el-button size="large" @click="handleFavorite">
|
||||||
<el-icon class="mr-1">
|
<el-icon class="mr-1">
|
||||||
<StarFilled v-if="isFavorited" class="text-yellow-500" />
|
<StarFilled v-if="isFavorited" class="text-yellow-500"/>
|
||||||
<Star v-else />
|
<Star v-else/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
{{ isFavorited ? '已收藏' : '收藏' }}
|
{{ isFavorited ? '已收藏' : '收藏' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 商品信息 -->
|
<!-- 商品信息 -->
|
||||||
<div class="border-t pt-6">
|
<div class="border-t pt-6">
|
||||||
<h3 class="font-semibold mb-4">商品信息</h3>
|
<h3 class="font-semibold mb-4">商品信息</h3>
|
||||||
@@ -137,7 +143,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">用户评分</dt>
|
<dt class="text-gray-500">用户评分</dt>
|
||||||
<dd class="font-medium">{{ reviewSummary.averageRating.toFixed(1) }} / 5({{ reviewSummary.totalReviews }} 条)</dd>
|
<dd class="font-medium">{{ reviewSummary.averageRating.toFixed(1) }} / 5({{
|
||||||
|
reviewSummary.totalReviews
|
||||||
|
}} 条)
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">上架时间</dt>
|
<dt class="text-gray-500">上架时间</dt>
|
||||||
@@ -151,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 商品详情 -->
|
<!-- 商品详情 -->
|
||||||
<div class="border-t">
|
<div class="border-t">
|
||||||
<el-tabs v-model="activeTab" class="px-8">
|
<el-tabs v-model="activeTab" class="px-8">
|
||||||
@@ -160,24 +169,24 @@
|
|||||||
<div class="prose max-w-none" v-html="product.description || '暂无详细描述'"></div>
|
<div class="prose max-w-none" v-html="product.description || '暂无详细描述'"></div>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="用户评价" name="reviews">
|
<el-tab-pane label="用户评价" name="reviews">
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="mb-6 bg-gray-50 rounded-lg p-4">
|
<div class="mb-6 bg-gray-50 rounded-lg p-4">
|
||||||
<div class="flex items-center gap-8 mb-4">
|
<div class="flex items-center gap-8 mb-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
<div class="text-3xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
||||||
<el-rate :model-value="reviewSummary.averageRating" disabled class="mt-1" />
|
<el-rate :model-value="reviewSummary.averageRating" class="mt-1" disabled/>
|
||||||
<div class="text-sm text-gray-500 mt-1">累计 {{ reviewSummary.totalReviews }} 条评价</div>
|
<div class="text-sm text-gray-500 mt-1">累计 {{ reviewSummary.totalReviews }} 条评价</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 space-y-1">
|
<div class="flex-1 space-y-1">
|
||||||
<div v-for="star in [5, 4, 3, 2, 1]" :key="star" class="flex items-center gap-2">
|
<div v-for="star in [5, 4, 3, 2, 1]" :key="star" class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-500 w-8">{{ star }}星</span>
|
<span class="text-sm text-gray-500 w-8">{{ star }}星</span>
|
||||||
<el-progress
|
<el-progress
|
||||||
:percentage="getRatingPercentage(star)"
|
:percentage="getRatingPercentage(star)"
|
||||||
:stroke-width="12"
|
:show-text="false"
|
||||||
:show-text="false"
|
:stroke-width="12"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-400 w-10 text-right">{{ getRatingCount(star) }}</span>
|
<span class="text-sm text-gray-400 w-10 text-right">{{ getRatingCount(star) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,23 +199,24 @@
|
|||||||
<div class="font-semibold">{{ review.username }}</div>
|
<div class="font-semibold">{{ review.username }}</div>
|
||||||
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
|
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<el-rate :model-value="review.rating" disabled />
|
<el-rate :model-value="review.rating" disabled/>
|
||||||
<p class="text-gray-600 mt-3 leading-6">{{ review.content }}</p>
|
<p class="text-gray-600 mt-3 leading-6">{{ review.content }}</p>
|
||||||
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-600">
|
<div v-if="review.adminReply"
|
||||||
|
class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-600">
|
||||||
<div class="font-medium text-gray-800 mb-1">商家回复</div>
|
<div class="font-medium text-gray-800 mb-1">商家回复</div>
|
||||||
<div>{{ review.adminReply }}</div>
|
<div>{{ review.adminReply }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="reviewSummary.reviews.length > reviewPageSize" class="flex justify-center mt-6">
|
<div v-if="reviewSummary.reviews.length > reviewPageSize" class="flex justify-center mt-6">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="reviewPage"
|
v-model:current-page="reviewPage"
|
||||||
:page-size="reviewPageSize"
|
:page-size="reviewPageSize"
|
||||||
:total="reviewSummary.reviews.length"
|
:total="reviewSummary.reviews.length"
|
||||||
layout="prev, pager, next"
|
layout="prev, pager, next"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="暂无评价" />
|
<el-empty v-else description="暂无评价"/>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
@@ -215,7 +225,7 @@
|
|||||||
<p class="text-gray-500">暂无规格参数</p>
|
<p class="text-gray-500">暂无规格参数</p>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="售后保障" name="service">
|
<el-tab-pane label="售后保障" name="service">
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<ul class="space-y-2 text-gray-600">
|
<ul class="space-y-2 text-gray-600">
|
||||||
@@ -233,20 +243,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import { reviewApi } from '@/api/modules/review'
|
import {reviewApi} from '@/api/modules/review'
|
||||||
import type { ReviewItem } from '@/api/modules/review'
|
import type {ReviewItem} from '@/api/modules/review'
|
||||||
import { favoriteApi } from '@/api/modules/favorite'
|
import {favoriteApi} from '@/api/modules/favorite'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { Product } from '@/types/api'
|
import type {Product} from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import { DEFAULT_PRODUCT_IMAGE, resolveImageUrl } from '@/utils/image'
|
import {DEFAULT_PRODUCT_IMAGE, resolveImageUrl} from '@/utils/image'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -259,7 +269,7 @@ const currentImage = ref('')
|
|||||||
const quantity = ref(1)
|
const quantity = ref(1)
|
||||||
const activeTab = ref('detail')
|
const activeTab = ref('detail')
|
||||||
const isFavorited = ref(false)
|
const isFavorited = ref(false)
|
||||||
const reviewSummary = ref({ averageRating: 0, totalReviews: 0, reviews: [] as ReviewItem[] })
|
const reviewSummary = ref({averageRating: 0, totalReviews: 0, reviews: [] as ReviewItem[]})
|
||||||
const reviewPage = ref(1)
|
const reviewPage = ref(1)
|
||||||
const reviewPageSize = 10
|
const reviewPageSize = 10
|
||||||
const defaultProductImage = DEFAULT_PRODUCT_IMAGE
|
const defaultProductImage = DEFAULT_PRODUCT_IMAGE
|
||||||
@@ -297,7 +307,7 @@ const loadProductDetail = async () => {
|
|||||||
try {
|
try {
|
||||||
const id = Number(route.params.id)
|
const id = Number(route.params.id)
|
||||||
const res = await productApi.getDetail(id)
|
const res = await productApi.getDetail(id)
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
product.value = res.data
|
product.value = res.data
|
||||||
currentImage.value = resolveImageUrl(res.data.imageUrl || res.data.images?.[0] || '')
|
currentImage.value = resolveImageUrl(res.data.imageUrl || res.data.images?.[0] || '')
|
||||||
@@ -334,13 +344,13 @@ const handleAddToCart = async () => {
|
|||||||
ElMessage.warning('请先登录')
|
ElMessage.warning('请先登录')
|
||||||
router.push({
|
router.push({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
query: { redirect: route.fullPath }
|
query: {redirect: route.fullPath}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product.value) return
|
if (!product.value) return
|
||||||
|
|
||||||
const success = await cartStore.addToCart(product.value.id, quantity.value)
|
const success = await cartStore.addToCart(product.value.id, quantity.value)
|
||||||
if (success) {
|
if (success) {
|
||||||
quantity.value = 1
|
quantity.value = 1
|
||||||
@@ -353,13 +363,13 @@ const handleBuyNow = async () => {
|
|||||||
ElMessage.warning('请先登录')
|
ElMessage.warning('请先登录')
|
||||||
router.push({
|
router.push({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
query: { redirect: route.fullPath }
|
query: {redirect: route.fullPath}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product.value) return
|
if (!product.value) return
|
||||||
|
|
||||||
// 先加入购物车
|
// 先加入购物车
|
||||||
const success = await cartStore.addToCart(product.value.id, quantity.value)
|
const success = await cartStore.addToCart(product.value.id, quantity.value)
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -374,7 +384,7 @@ const handleFavorite = async () => {
|
|||||||
ElMessage.warning('请先登录')
|
ElMessage.warning('请先登录')
|
||||||
router.push({
|
router.push({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
query: { redirect: route.fullPath }
|
query: {redirect: route.fullPath}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -396,7 +406,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.product-detail-page {
|
.product-detail-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -404,7 +414,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|
||||||
:deep(img) {
|
:deep(img) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -4,28 +4,30 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
||||||
<el-icon class="page-icon mr-2"><ShoppingBag /></el-icon>
|
<el-icon class="page-icon mr-2">
|
||||||
|
<ShoppingBag/>
|
||||||
|
</el-icon>
|
||||||
商品列表
|
商品列表
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600">精选好物,品质保证</p>
|
<p class="text-gray-600">精选好物,品质保证</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分类标签栏 -->
|
<!-- 分类标签栏 -->
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<el-tag
|
<el-tag
|
||||||
:effect="!filters.category ? 'dark' : 'plain'"
|
:effect="!filters.category ? 'dark' : 'plain'"
|
||||||
class="cursor-pointer category-tag"
|
class="cursor-pointer category-tag"
|
||||||
@click="selectCategory('')"
|
@click="selectCategory('')"
|
||||||
>
|
>
|
||||||
全部
|
全部
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
:effect="filters.category === cat ? 'dark' : 'plain'"
|
:effect="filters.category === cat ? 'dark' : 'plain'"
|
||||||
class="cursor-pointer category-tag"
|
class="cursor-pointer category-tag"
|
||||||
@click="selectCategory(cat)"
|
@click="selectCategory(cat)"
|
||||||
>
|
>
|
||||||
{{ cat }}
|
{{ cat }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -37,39 +39,39 @@
|
|||||||
<div class="flex flex-wrap gap-4 items-center">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
<!-- 分类筛选 -->
|
<!-- 分类筛选 -->
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.category"
|
v-model="filters.category"
|
||||||
placeholder="选择分类"
|
clearable
|
||||||
clearable
|
placeholder="选择分类"
|
||||||
style="width: 150px"
|
style="width: 150px"
|
||||||
@change="loadProducts"
|
@change="loadProducts"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
:label="cat"
|
:label="cat"
|
||||||
:value="cat"
|
:value="cat"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
<!-- 价格区间 -->
|
<!-- 价格区间 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="filters.minPrice"
|
v-model="filters.minPrice"
|
||||||
:min="0"
|
:min="0"
|
||||||
placeholder="最低价"
|
placeholder="最低价"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
@change="loadProducts"
|
@change="loadProducts"
|
||||||
/>
|
/>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="filters.maxPrice"
|
v-model="filters.maxPrice"
|
||||||
:min="0"
|
:min="0"
|
||||||
placeholder="最高价"
|
placeholder="最高价"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
@change="loadProducts"
|
@change="loadProducts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 排序 -->
|
<!-- 排序 -->
|
||||||
<el-radio-group v-model="filters.sort" @change="loadProducts">
|
<el-radio-group v-model="filters.sort" @change="loadProducts">
|
||||||
<el-radio-button label="default">默认</el-radio-button>
|
<el-radio-button label="default">默认</el-radio-button>
|
||||||
@@ -77,54 +79,56 @@
|
|||||||
<el-radio-button label="price-desc">价格降序</el-radio-button>
|
<el-radio-button label="price-desc">价格降序</el-radio-button>
|
||||||
<el-radio-button label="sales">销量</el-radio-button>
|
<el-radio-button label="sales">销量</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
placeholder="搜索商品"
|
clearable
|
||||||
style="width: 200px"
|
placeholder="搜索商品"
|
||||||
clearable
|
style="width: 200px"
|
||||||
@keyup.enter="loadProducts"
|
@keyup.enter="loadProducts"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<el-icon class="cursor-pointer" @click="loadProducts">
|
<el-icon class="cursor-pointer" @click="loadProducts">
|
||||||
<Search />
|
<Search/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 商品列表 -->
|
<!-- 商品列表 -->
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="products.length === 0" class="text-center py-12">
|
<div v-else-if="products.length === 0" class="text-center py-12">
|
||||||
<el-empty description="暂无商品" />
|
<el-empty description="暂无商品"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<ProductCard
|
<ProductCard
|
||||||
v-for="item in products"
|
v-for="item in products"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:data="item"
|
:data="item"
|
||||||
@add-to-cart="handleAddToCart"
|
@add-to-cart="handleAddToCart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="mt-8 flex justify-center">
|
<div class="mt-8 flex justify-center">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pagination.page"
|
v-model:current-page="pagination.page"
|
||||||
v-model:page-size="pagination.size"
|
v-model:page-size="pagination.size"
|
||||||
:total="pagination.total"
|
:page-sizes="[12, 24, 36, 48]"
|
||||||
:page-sizes="[12, 24, 36, 48]"
|
:total="pagination.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@size-change="loadProducts"
|
@size-change="loadProducts"
|
||||||
@current-change="loadProducts"
|
@current-change="loadProducts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,15 +136,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import {ref, reactive, onMounted, watch} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import ProductCard from '@/components/business/ProductCard.vue'
|
import ProductCard from '@/components/business/ProductCard.vue'
|
||||||
import { productApi } from '@/api/modules/product'
|
import {productApi} from '@/api/modules/product'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { Product } from '@/types/api'
|
import type {Product} from '@/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -176,12 +180,12 @@ const loadProducts = async () => {
|
|||||||
page: pagination.page - 1,
|
page: pagination.page - 1,
|
||||||
size: pagination.size
|
size: pagination.size
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.keyword) params.keyword = filters.keyword
|
if (filters.keyword) params.keyword = filters.keyword
|
||||||
if (filters.category) params.category = filters.category
|
if (filters.category) params.category = filters.category
|
||||||
if (filters.minPrice !== undefined) params.minPrice = filters.minPrice
|
if (filters.minPrice !== undefined) params.minPrice = filters.minPrice
|
||||||
if (filters.maxPrice !== undefined) params.maxPrice = filters.maxPrice
|
if (filters.maxPrice !== undefined) params.maxPrice = filters.maxPrice
|
||||||
|
|
||||||
// 处理排序
|
// 处理排序
|
||||||
if (filters.sort === 'price-asc') {
|
if (filters.sort === 'price-asc') {
|
||||||
params.sort = 'price'
|
params.sort = 'price'
|
||||||
@@ -193,9 +197,9 @@ const loadProducts = async () => {
|
|||||||
params.sort = 'createdAt'
|
params.sort = 'createdAt'
|
||||||
params.order = 'desc'
|
params.order = 'desc'
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await productApi.getList(params)
|
const res = await productApi.getList(params)
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
products.value = res.data.content
|
products.value = res.data.content
|
||||||
pagination.total = res.data.totalElements
|
pagination.total = res.data.totalElements
|
||||||
@@ -225,7 +229,7 @@ const selectCategory = (cat: string) => {
|
|||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
loadProducts()
|
loadProducts()
|
||||||
// 同步 URL
|
// 同步 URL
|
||||||
router.replace({ query: { ...route.query, category: cat || undefined } })
|
router.replace({query: {...route.query, category: cat || undefined}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加到购物车
|
// 添加到购物车
|
||||||
@@ -235,7 +239,7 @@ const handleAddToCart = async (productId: number) => {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await cartStore.addToCart(productId)
|
await cartStore.addToCart(productId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +270,7 @@ watch(() => route.query, (newQuery) => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.products-page {
|
.products-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -3,14 +3,18 @@
|
|||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
||||||
<el-icon class="text-pink-500 mr-2"><StarFilled /></el-icon>
|
<el-icon class="text-pink-500 mr-2">
|
||||||
|
<StarFilled/>
|
||||||
|
</el-icon>
|
||||||
我的收藏
|
我的收藏
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600">收藏你感兴趣的商品,随时回来查看</p>
|
<p class="text-gray-600">收藏你感兴趣的商品,随时回来查看</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,14 +25,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<div v-for="item in favorites" :key="item.id" class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
|
<div v-for="item in favorites" :key="item.id"
|
||||||
|
class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="item.productImageUrl"
|
:alt="item.productName"
|
||||||
:alt="item.productName"
|
:clickable="true"
|
||||||
wrapper-class="w-full h-48 bg-gray-100"
|
:src="item.productImageUrl"
|
||||||
img-class="w-full h-48 object-cover"
|
img-class="w-full h-48 object-cover"
|
||||||
:clickable="true"
|
wrapper-class="w-full h-48 bg-gray-100"
|
||||||
@click="router.push(`/product/${item.productId}`)"
|
@click="router.push(`/product/${item.productId}`)"
|
||||||
/>
|
/>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h3 class="font-semibold mb-2 line-clamp-1">{{ item.productName }}</h3>
|
<h3 class="font-semibold mb-2 line-clamp-1">{{ item.productName }}</h3>
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
<span class="text-xs text-gray-400">收藏于 {{ formatTime(item.createdAt) }}</span>
|
<span class="text-xs text-gray-400">收藏于 {{ formatTime(item.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<el-button type="primary" class="flex-1" @click="addToCart(item.productId)">加入购物车</el-button>
|
<el-button class="flex-1" type="primary" @click="addToCart(item.productId)">加入购物车</el-button>
|
||||||
<el-button @click="toggleFavorite(item.productId)">取消收藏</el-button>
|
<el-button @click="toggleFavorite(item.productId)">取消收藏</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,13 +53,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import {onMounted, ref} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { favoriteApi, type FavoriteItem } from '@/api/modules/favorite'
|
import {favoriteApi, type FavoriteItem} from '@/api/modules/favorite'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import {useCartStore} from '@/stores/cart'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -91,7 +96,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.favorites-page {
|
.favorites-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -5,63 +5,63 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<el-icon :size="48" class="page-mark mb-4">
|
<el-icon :size="48" class="page-mark mb-4">
|
||||||
<Lightning />
|
<Lightning/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
||||||
<p class="text-gray-600 mt-2">登录到社区生鲜团购系统</p>
|
<p class="text-gray-600 mt-2">登录到社区生鲜团购系统</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 登录表单 -->
|
<!-- 登录表单 -->
|
||||||
<el-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="loginForm"
|
:model="loginForm"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
@submit.prevent
|
@submit.prevent
|
||||||
>
|
>
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="loginForm.username"
|
v-model="loginForm.username"
|
||||||
size="large"
|
clearable
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
prefix-icon="User"
|
prefix-icon="User"
|
||||||
clearable
|
size="large"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="password">
|
<el-form-item prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="loginForm.password"
|
v-model="loginForm.password"
|
||||||
type="password"
|
placeholder="请输入密码"
|
||||||
size="large"
|
prefix-icon="Lock"
|
||||||
placeholder="请输入密码"
|
show-password
|
||||||
prefix-icon="Lock"
|
size="large"
|
||||||
show-password
|
type="password"
|
||||||
@keyup.enter="handleLogin"
|
@keyup.enter="handleLogin"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<div class="flex justify-between items-center w-full">
|
<div class="flex justify-between items-center w-full">
|
||||||
<el-checkbox v-model="loginForm.rememberMe">记住我</el-checkbox>
|
<el-checkbox v-model="loginForm.rememberMe">记住我</el-checkbox>
|
||||||
<el-link type="primary" :underline="false">忘记密码?</el-link>
|
<el-link :underline="false" type="primary">忘记密码?</el-link>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
:loading="loading"
|
||||||
size="large"
|
class="w-full"
|
||||||
class="w-full"
|
size="large"
|
||||||
:loading="loading"
|
type="primary"
|
||||||
@click="handleLogin"
|
@click="handleLogin"
|
||||||
>
|
>
|
||||||
登 录
|
登 录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-gray-600">还没有账号?</span>
|
<span class="text-gray-600">还没有账号?</span>
|
||||||
<router-link to="/register" class="text-primary-500 hover:underline">
|
<router-link class="text-primary-500 hover:underline" to="/register">
|
||||||
立即注册
|
立即注册
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,11 +71,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive } from 'vue'
|
import {ref, reactive} from 'vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type {FormInstance, FormRules} from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import type { LoginParams } from '@/types/api'
|
import type {LoginParams} from '@/types/api'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@@ -90,19 +90,19 @@ const loginForm = reactive<LoginParams>({
|
|||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
{required: true, message: '请输入用户名', trigger: 'blur'},
|
||||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
|
{min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur'}
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{required: true, message: '请输入密码', trigger: 'blur'},
|
||||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
{min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur'}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
|
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -119,7 +119,7 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.login-page {
|
.login-page {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto py-6 px-4">
|
<div class="max-w-4xl mx-auto py-6 px-4">
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>消息通知</el-breadcrumb-item>
|
<el-breadcrumb-item>消息通知</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-semibold">消息通知</h2>
|
<h2 class="text-xl font-semibold">消息通知</h2>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<el-button size="small" @click="handleMarkAllRead" :disabled="unreadCount === 0">
|
<el-button :disabled="unreadCount === 0" size="small" @click="handleMarkAllRead">
|
||||||
全部已读
|
全部已读
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleClearAll" :disabled="notifications.length === 0">
|
<el-button :disabled="notifications.length === 0" plain size="small" type="danger" @click="handleClearAll">
|
||||||
清空全部
|
清空全部
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,45 +21,47 @@
|
|||||||
|
|
||||||
<!-- 标签筛选 -->
|
<!-- 标签筛选 -->
|
||||||
<el-tabs v-model="activeType" @tab-change="loadNotifications">
|
<el-tabs v-model="activeType" @tab-change="loadNotifications">
|
||||||
<el-tab-pane label="全部" name="all" />
|
<el-tab-pane label="全部" name="all"/>
|
||||||
<el-tab-pane label="限时" name="flashsale"/>
|
<el-tab-pane label="限时" name="flashsale"/>
|
||||||
<el-tab-pane label="订单" name="order" />
|
<el-tab-pane label="订单" name="order"/>
|
||||||
<el-tab-pane label="系统" name="system" />
|
<el-tab-pane label="系统" name="system"/>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="32" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="32" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 通知列表 -->
|
<!-- 通知列表 -->
|
||||||
<div v-else-if="notifications.length > 0" class="space-y-3">
|
<div v-else-if="notifications.length > 0" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="item in notifications"
|
v-for="item in notifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50"
|
:class="{ 'bg-orange-50/50 border-orange-200': !item.read }"
|
||||||
:class="{ 'bg-orange-50/50 border-orange-200': !item.read }"
|
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<el-icon :size="20" class="mt-0.5" :class="getIconColor(item.type)">
|
<el-icon :class="getIconColor(item.type)" :size="20" class="mt-0.5">
|
||||||
<component :is="getIcon(item.type)" />
|
<component :is="getIcon(item.type)"/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="font-medium" :class="{ 'font-semibold': !item.read }">{{ item.title }}</span>
|
<span :class="{ 'font-semibold': !item.read }" class="font-medium">{{ item.title }}</span>
|
||||||
<el-tag v-if="!item.read" type="danger" size="small" effect="light">未读</el-tag>
|
<el-tag v-if="!item.read" effect="light" size="small" type="danger">未读</el-tag>
|
||||||
<el-tag size="small" effect="plain">{{ getTypeLabel(item.type) }}</el-tag>
|
<el-tag effect="plain" size="small">{{ getTypeLabel(item.type) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 mb-2">{{ item.message }}</p>
|
<p class="text-sm text-gray-600 mb-2">{{ item.message }}</p>
|
||||||
<span class="text-xs text-gray-400">{{ formatTime(item.createdAt) }}</span>
|
<span class="text-xs text-gray-400">{{ formatTime(item.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!item.read"
|
v-if="!item.read"
|
||||||
text
|
size="small"
|
||||||
size="small"
|
text
|
||||||
@click.stop="handleMarkRead(item)"
|
@click.stop="handleMarkRead(item)"
|
||||||
>
|
>
|
||||||
标记已读
|
标记已读
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -68,16 +70,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<el-empty v-else description="暂无消息通知" class="py-12" />
|
<el-empty v-else class="py-12" description="暂无消息通知"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { notificationApi } from '@/api/modules/notification'
|
import {notificationApi} from '@/api/modules/notification'
|
||||||
import type { NotificationItem } from '@/api/modules/notification'
|
import type {NotificationItem} from '@/api/modules/notification'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import 'dayjs/locale/zh-cn'
|
import 'dayjs/locale/zh-cn'
|
||||||
@@ -128,7 +130,7 @@ const handleMarkAllRead = async () => {
|
|||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定要清空所有通知吗?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确定要清空所有通知吗?', '提示', {type: 'warning'})
|
||||||
await notificationApi.clearAll()
|
await notificationApi.clearAll()
|
||||||
notifications.value = []
|
notifications.value = []
|
||||||
ElMessage.success('已清空所有通知')
|
ElMessage.success('已清空所有通知')
|
||||||
@@ -139,7 +141,8 @@ const handleClearAll = async () => {
|
|||||||
|
|
||||||
const handleClick = async (item: NotificationItem) => {
|
const handleClick = async (item: NotificationItem) => {
|
||||||
if (!item.read) {
|
if (!item.read) {
|
||||||
await notificationApi.markAsRead(item.id).catch(() => {})
|
await notificationApi.markAsRead(item.id).catch(() => {
|
||||||
|
})
|
||||||
item.read = true
|
item.read = true
|
||||||
}
|
}
|
||||||
if (item.link) {
|
if (item.link) {
|
||||||
@@ -32,11 +32,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu :default-active="activeMenu" @select="handleMenuSelect">
|
<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="info">
|
||||||
<el-menu-item index="security"><el-icon><Lock /></el-icon><span>账号安全</span></el-menu-item>
|
<el-icon>
|
||||||
<el-menu-item index="address"><el-icon><Location /></el-icon><span>收货地址</span></el-menu-item>
|
<User/>
|
||||||
<el-menu-item index="orders"><el-icon><List /></el-icon><span>我的订单</span></el-menu-item>
|
</el-icon>
|
||||||
<el-menu-item index="favorites"><el-icon><Star /></el-icon><span>我的收藏</span></el-menu-item>
|
<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-item index="favorites">
|
||||||
|
<el-icon>
|
||||||
|
<Star/>
|
||||||
|
</el-icon>
|
||||||
|
<span>我的收藏</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,33 +66,54 @@
|
|||||||
<div v-if="activeMenu === 'info'">
|
<div v-if="activeMenu === 'info'">
|
||||||
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
|
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
|
||||||
<el-form ref="infoFormRef" :model="infoForm" :rules="infoRules" label-width="100px">
|
<el-form ref="infoFormRef" :model="infoForm" :rules="infoRules" label-width="100px">
|
||||||
<el-form-item label="用户名"><el-input v-model="infoForm.username" disabled /></el-form-item>
|
<el-form-item label="用户名">
|
||||||
<el-form-item label="邮箱" prop="email"><el-input v-model="infoForm.email" /></el-form-item>
|
<el-input v-model="infoForm.username" disabled/>
|
||||||
<el-form-item label="手机号" prop="phone"><el-input v-model="infoForm.phone" /></el-form-item>
|
</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="头像">
|
<el-form-item label="头像">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<el-avatar :size="60" :src="infoForm.avatar">{{ (infoForm.username || 'U')[0] }}</el-avatar>
|
<el-avatar :size="60" :src="infoForm.avatar">{{ (infoForm.username || 'U')[0] }}</el-avatar>
|
||||||
<el-button @click="handleUpdateAvatar">更换头像</el-button>
|
<el-button @click="handleUpdateAvatar">更换头像</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item><el-button type="primary" @click="handleSaveInfo">保存修改</el-button></el-form-item>
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSaveInfo">保存修改</el-button>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeMenu === 'security'">
|
<div v-else-if="activeMenu === 'security'">
|
||||||
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
|
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
|
||||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
|
<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="oldPassword">
|
||||||
<el-form-item label="新密码" prop="newPassword"><el-input v-model="passwordForm.newPassword" type="password" show-password /></el-form-item>
|
<el-input v-model="passwordForm.oldPassword" show-password type="password"/>
|
||||||
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
|
</el-form-item>
|
||||||
<el-form-item><el-button type="primary" @click="handleChangePassword">修改密码</el-button></el-form-item>
|
<el-form-item label="新密码" prop="newPassword">
|
||||||
|
<el-input v-model="passwordForm.newPassword" show-password type="password"/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirmPassword">
|
||||||
|
<el-input v-model="passwordForm.confirmPassword" show-password type="password"/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleChangePassword">修改密码</el-button>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeMenu === 'address'">
|
<div v-else-if="activeMenu === 'address'">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-semibold">收货地址</h2>
|
<h2 class="text-xl font-semibold">收货地址</h2>
|
||||||
<el-button type="primary" @click="openAddressDialog()"><el-icon class="mr-1"><Plus /></el-icon>添加地址</el-button>
|
<el-button type="primary" @click="openAddressDialog()">
|
||||||
|
<el-icon class="mr-1">
|
||||||
|
<Plus/>
|
||||||
|
</el-icon>
|
||||||
|
添加地址
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-for="addr in addresses" :key="addr.id" class="border rounded-lg p-4">
|
<div v-for="addr in addresses" :key="addr.id" class="border rounded-lg p-4">
|
||||||
@@ -81,18 +122,22 @@
|
|||||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<span class="font-semibold">{{ addr.name }}</span>
|
<span class="font-semibold">{{ addr.name }}</span>
|
||||||
<span class="text-gray-500">{{ addr.phone }}</span>
|
<span class="text-gray-500">{{ addr.phone }}</span>
|
||||||
<el-tag v-if="addr.isDefault" type="primary" size="small">默认</el-tag>
|
<el-tag v-if="addr.isDefault" size="small" type="primary">默认</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600">{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}</p>
|
<p class="text-gray-600">{{ addr.province }} {{ addr.city }} {{ addr.district }} {{
|
||||||
|
addr.address
|
||||||
|
}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-x-2 whitespace-nowrap">
|
<div class="space-x-2 whitespace-nowrap">
|
||||||
<el-button v-if="!addr.isDefault" text type="success" size="small" @click="setDefaultAddress(addr.id)">设为默认</el-button>
|
<el-button v-if="!addr.isDefault" size="small" text type="success"
|
||||||
<el-button text type="primary" size="small" @click="openAddressDialog(addr)">编辑</el-button>
|
@click="setDefaultAddress(addr.id)">设为默认
|
||||||
<el-button text type="danger" size="small" @click="removeAddress(addr.id)">删除</el-button>
|
</el-button>
|
||||||
|
<el-button size="small" text type="primary" @click="openAddressDialog(addr)">编辑</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="removeAddress(addr.id)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="addresses.length === 0" description="暂无收货地址" />
|
<el-empty v-if="addresses.length === 0" description="暂无收货地址"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,16 +158,40 @@
|
|||||||
<el-dialog v-model="addressDialogVisible" :title="editingAddressId ? '编辑地址' : '新增地址'" width="620px">
|
<el-dialog v-model="addressDialogVisible" :title="editingAddressId ? '编辑地址' : '新增地址'" width="620px">
|
||||||
<el-form ref="addressFormRef" :model="addressForm" :rules="addressRules" label-width="90px">
|
<el-form ref="addressFormRef" :model="addressForm" :rules="addressRules" label-width="90px">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12"><el-form-item label="收货人" prop="name"><el-input v-model="addressForm.name" /></el-form-item></el-col>
|
<el-col :span="12">
|
||||||
<el-col :span="12"><el-form-item label="手机号" prop="phone"><el-input v-model="addressForm.phone" /></el-form-item></el-col>
|
<el-form-item label="收货人" prop="name">
|
||||||
|
<el-input v-model="addressForm.name"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="addressForm.phone"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="8"><el-form-item label="省份" prop="province"><el-input v-model="addressForm.province" /></el-form-item></el-col>
|
<el-col :span="8">
|
||||||
<el-col :span="8"><el-form-item label="城市" prop="city"><el-input v-model="addressForm.city" /></el-form-item></el-col>
|
<el-form-item label="省份" prop="province">
|
||||||
<el-col :span="8"><el-form-item label="区县" prop="district"><el-input v-model="addressForm.district" /></el-form-item></el-col>
|
<el-input v-model="addressForm.province"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="城市" prop="city">
|
||||||
|
<el-input v-model="addressForm.city"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="区县" prop="district">
|
||||||
|
<el-input v-model="addressForm.district"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="详细地址" prop="address"><el-input v-model="addressForm.address" type="textarea" :rows="3" /></el-form-item>
|
<el-form-item label="详细地址" prop="address">
|
||||||
<el-form-item><el-checkbox v-model="addressForm.isDefault">设为默认地址</el-checkbox></el-form-item>
|
<el-input v-model="addressForm.address" :rows="3" type="textarea"/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-checkbox v-model="addressForm.isDefault">设为默认地址</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="addressDialogVisible = false">取消</el-button>
|
<el-button @click="addressDialogVisible = false">取消</el-button>
|
||||||
@@ -132,14 +201,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import {onMounted, reactive, ref, watch} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type {FormInstance, FormRules} from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
import { userApi } from '@/api/modules/user'
|
import {userApi} from '@/api/modules/user'
|
||||||
import { addressApi, type AddressItem } from '@/api/modules/address'
|
import {addressApi, type AddressItem} from '@/api/modules/address'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -152,15 +221,28 @@ const addressFormRef = ref<FormInstance>()
|
|||||||
const addressDialogVisible = ref(false)
|
const addressDialogVisible = ref(false)
|
||||||
const editingAddressId = ref<number | null>(null)
|
const editingAddressId = ref<number | null>(null)
|
||||||
const addresses = ref<AddressItem[]>([])
|
const addresses = ref<AddressItem[]>([])
|
||||||
const profileStats = reactive({ totalOrders: 0, totalAmount: 0, flashSaleSuccess: 0, favoriteCount: 0 })
|
const profileStats = reactive({totalOrders: 0, totalAmount: 0, flashSaleSuccess: 0, favoriteCount: 0})
|
||||||
|
|
||||||
const infoForm = reactive({ username: '', email: '', phone: '', avatar: '' })
|
const infoForm = reactive({username: '', email: '', phone: '', avatar: ''})
|
||||||
const passwordForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
const passwordForm = reactive({oldPassword: '', newPassword: '', confirmPassword: ''})
|
||||||
const addressForm = reactive<AddressItem>({ id: 0, name: '', phone: '', province: '', city: '', district: '', address: '', isDefault: false })
|
const addressForm = reactive<AddressItem>({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
district: '',
|
||||||
|
address: '',
|
||||||
|
isDefault: false
|
||||||
|
})
|
||||||
|
|
||||||
const infoRules: FormRules = {
|
const infoRules: FormRules = {
|
||||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
email: [{required: true, message: '请输入邮箱', trigger: 'blur'}, {
|
||||||
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
type: 'email',
|
||||||
|
message: '请输入正确的邮箱地址',
|
||||||
|
trigger: 'blur'
|
||||||
|
}],
|
||||||
|
phone: [{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
|
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
|
||||||
@@ -170,18 +252,23 @@ const validatePassword = (_rule: any, value: string, callback: (error?: Error) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordRules: FormRules = {
|
const passwordRules: FormRules = {
|
||||||
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
oldPassword: [{required: true, message: '请输入原密码', trigger: 'blur'}],
|
||||||
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }],
|
newPassword: [{required: true, message: '请输入新密码', trigger: 'blur'}, {
|
||||||
confirmPassword: [{ required: true, validator: validatePassword, trigger: 'blur' }],
|
min: 6,
|
||||||
|
max: 20,
|
||||||
|
message: '密码长度在 6 到 20 个字符',
|
||||||
|
trigger: 'blur'
|
||||||
|
}],
|
||||||
|
confirmPassword: [{required: true, validator: validatePassword, trigger: 'blur'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const addressRules: FormRules = {
|
const addressRules: FormRules = {
|
||||||
name: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
|
name: [{required: true, message: '请输入收货人', trigger: 'blur'}],
|
||||||
phone: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确手机号', trigger: 'blur' }],
|
phone: [{required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确手机号', trigger: 'blur'}],
|
||||||
province: [{ required: true, message: '请输入省份', trigger: 'blur' }],
|
province: [{required: true, message: '请输入省份', trigger: 'blur'}],
|
||||||
city: [{ required: true, message: '请输入城市', trigger: 'blur' }],
|
city: [{required: true, message: '请输入城市', trigger: 'blur'}],
|
||||||
district: [{ required: true, message: '请输入区县', trigger: 'blur' }],
|
district: [{required: true, message: '请输入区县', trigger: 'blur'}],
|
||||||
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }],
|
address: [{required: true, message: '请输入详细地址', trigger: 'blur'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncInfoForm = () => {
|
const syncInfoForm = () => {
|
||||||
@@ -216,7 +303,16 @@ const loadProfileStats = async () => {
|
|||||||
|
|
||||||
const resetAddressForm = () => {
|
const resetAddressForm = () => {
|
||||||
editingAddressId.value = null
|
editingAddressId.value = null
|
||||||
Object.assign(addressForm, { id: 0, name: '', phone: '', province: '', city: '', district: '', address: '', isDefault: false })
|
Object.assign(addressForm, {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
district: '',
|
||||||
|
address: '',
|
||||||
|
isDefault: false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuSelect = (index: string) => {
|
const handleMenuSelect = (index: string) => {
|
||||||
@@ -224,7 +320,7 @@ const handleMenuSelect = (index: string) => {
|
|||||||
if (index === 'orders') router.push('/orders')
|
if (index === 'orders') router.push('/orders')
|
||||||
else if (index === 'favorites') router.push('/favorites')
|
else if (index === 'favorites') router.push('/favorites')
|
||||||
else if (index === 'address') router.push('/addresses')
|
else if (index === 'address') router.push('/addresses')
|
||||||
else router.push({ path: '/profile', query: { tab: index } })
|
else router.push({path: '/profile', query: {tab: index}})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveInfo = async () => {
|
const handleSaveInfo = async () => {
|
||||||
@@ -232,7 +328,7 @@ const handleSaveInfo = async () => {
|
|||||||
await infoFormRef.value.validate(async (valid) => {
|
await infoFormRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
try {
|
try {
|
||||||
const res = await userApi.updateInfo({ email: infoForm.email, phone: infoForm.phone, avatar: infoForm.avatar })
|
const res = await userApi.updateInfo({email: infoForm.email, phone: infoForm.phone, avatar: infoForm.avatar})
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
userStore.updateUserInfo(res.data)
|
userStore.updateUserInfo(res.data)
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
@@ -245,11 +341,16 @@ const handleSaveInfo = async () => {
|
|||||||
|
|
||||||
const handleUpdateAvatar = async () => {
|
const handleUpdateAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
const { value } = await ElMessageBox.prompt('请输入新的头像图片 URL', '更换头像', { inputValue: infoForm.avatar, confirmButtonText: '保存', cancelButtonText: '取消' })
|
const {value} = await ElMessageBox.prompt('请输入新的头像图片 URL', '更换头像', {
|
||||||
|
inputValue: infoForm.avatar,
|
||||||
|
confirmButtonText: '保存',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
infoForm.avatar = value || ''
|
infoForm.avatar = value || ''
|
||||||
userStore.updateUserInfo({ avatar: infoForm.avatar })
|
userStore.updateUserInfo({avatar: infoForm.avatar})
|
||||||
ElMessage.success('头像已更新')
|
ElMessage.success('头像已更新')
|
||||||
} catch {}
|
} catch {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePassword = async () => {
|
||||||
@@ -257,7 +358,11 @@ const handleChangePassword = async () => {
|
|||||||
await passwordFormRef.value.validate(async (valid) => {
|
await passwordFormRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
try {
|
try {
|
||||||
const res = await userApi.changePassword({ oldPassword: passwordForm.oldPassword, newPassword: passwordForm.newPassword, confirmPassword: passwordForm.confirmPassword })
|
const res = await userApi.changePassword({
|
||||||
|
oldPassword: passwordForm.oldPassword,
|
||||||
|
newPassword: passwordForm.newPassword,
|
||||||
|
confirmPassword: passwordForm.confirmPassword
|
||||||
|
})
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
ElMessage.success('密码修改成功,请重新登录')
|
ElMessage.success('密码修改成功,请重新登录')
|
||||||
await userStore.logout()
|
await userStore.logout()
|
||||||
@@ -281,7 +386,15 @@ const saveAddress = async () => {
|
|||||||
if (!addressFormRef.value) return
|
if (!addressFormRef.value) return
|
||||||
await addressFormRef.value.validate(async (valid) => {
|
await addressFormRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
const payload = { name: addressForm.name, phone: addressForm.phone, province: addressForm.province, city: addressForm.city, district: addressForm.district, address: addressForm.address, isDefault: addressForm.isDefault }
|
const payload = {
|
||||||
|
name: addressForm.name,
|
||||||
|
phone: addressForm.phone,
|
||||||
|
province: addressForm.province,
|
||||||
|
city: addressForm.city,
|
||||||
|
district: addressForm.district,
|
||||||
|
address: addressForm.address,
|
||||||
|
isDefault: addressForm.isDefault
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (editingAddressId.value) {
|
if (editingAddressId.value) {
|
||||||
await addressApi.update(editingAddressId.value, payload)
|
await addressApi.update(editingAddressId.value, payload)
|
||||||
@@ -305,14 +418,14 @@ const setDefaultAddress = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeAddress = async (id: number) => {
|
const removeAddress = async (id: number) => {
|
||||||
await ElMessageBox.confirm('确定删除这个地址吗?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确定删除这个地址吗?', '提示', {type: 'warning'})
|
||||||
await addressApi.delete(id)
|
await addressApi.delete(id)
|
||||||
ElMessage.success('地址已删除')
|
ElMessage.success('地址已删除')
|
||||||
loadAddresses()
|
loadAddresses()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => userStore.user, syncInfoForm, { immediate: true })
|
watch(() => userStore.user, syncInfoForm, {immediate: true})
|
||||||
watch(() => route.fullPath, syncActiveMenu, { immediate: true })
|
watch(() => route.fullPath, syncActiveMenu, {immediate: true})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (userStore.token) await userStore.getUserInfo()
|
if (userStore.token) await userStore.getUserInfo()
|
||||||
@@ -323,7 +436,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -5,96 +5,96 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<el-icon :size="48" class="page-mark mb-4">
|
<el-icon :size="48" class="page-mark mb-4">
|
||||||
<Lightning />
|
<Lightning/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
|
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
|
||||||
<p class="text-gray-600 mt-2">加入社区生鲜团购系统</p>
|
<p class="text-gray-600 mt-2">加入社区生鲜团购系统</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 注册表单 -->
|
<!-- 注册表单 -->
|
||||||
<el-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="registerForm"
|
:model="registerForm"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
@submit.prevent
|
@submit.prevent
|
||||||
>
|
>
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.username"
|
v-model="registerForm.username"
|
||||||
size="large"
|
clearable
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
prefix-icon="User"
|
prefix-icon="User"
|
||||||
clearable
|
size="large"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="email">
|
<el-form-item prop="email">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.email"
|
v-model="registerForm.email"
|
||||||
size="large"
|
clearable
|
||||||
placeholder="请输入邮箱"
|
placeholder="请输入邮箱"
|
||||||
prefix-icon="Message"
|
prefix-icon="Message"
|
||||||
clearable
|
size="large"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="phone">
|
<el-form-item prop="phone">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.phone"
|
v-model="registerForm.phone"
|
||||||
size="large"
|
clearable
|
||||||
placeholder="请输入手机号(选填)"
|
placeholder="请输入手机号(选填)"
|
||||||
prefix-icon="Phone"
|
prefix-icon="Phone"
|
||||||
clearable
|
size="large"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="password">
|
<el-form-item prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.password"
|
v-model="registerForm.password"
|
||||||
type="password"
|
placeholder="请输入密码"
|
||||||
size="large"
|
prefix-icon="Lock"
|
||||||
placeholder="请输入密码"
|
show-password
|
||||||
prefix-icon="Lock"
|
size="large"
|
||||||
show-password
|
type="password"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="confirmPassword">
|
<el-form-item prop="confirmPassword">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.confirmPassword"
|
v-model="registerForm.confirmPassword"
|
||||||
type="password"
|
placeholder="请确认密码"
|
||||||
size="large"
|
prefix-icon="Lock"
|
||||||
placeholder="请确认密码"
|
show-password
|
||||||
prefix-icon="Lock"
|
size="large"
|
||||||
show-password
|
type="password"
|
||||||
@keyup.enter="handleRegister"
|
@keyup.enter="handleRegister"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="agreement">
|
<el-form-item prop="agreement">
|
||||||
<el-checkbox v-model="registerForm.agreement">
|
<el-checkbox v-model="registerForm.agreement">
|
||||||
我已阅读并同意
|
我已阅读并同意
|
||||||
<el-link type="primary" :underline="false">用户协议</el-link>
|
<el-link :underline="false" type="primary">用户协议</el-link>
|
||||||
和
|
和
|
||||||
<el-link type="primary" :underline="false">隐私政策</el-link>
|
<el-link :underline="false" type="primary">隐私政策</el-link>
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
:loading="loading"
|
||||||
size="large"
|
class="w-full"
|
||||||
class="w-full"
|
size="large"
|
||||||
:loading="loading"
|
type="primary"
|
||||||
@click="handleRegister"
|
@click="handleRegister"
|
||||||
>
|
>
|
||||||
注 册
|
注 册
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-gray-600">已有账号?</span>
|
<span class="text-gray-600">已有账号?</span>
|
||||||
<router-link to="/login" class="text-primary-500 hover:underline">
|
<router-link class="text-primary-500 hover:underline" to="/login">
|
||||||
立即登录
|
立即登录
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,12 +104,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive } from 'vue'
|
import {ref, reactive} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElForm } from 'element-plus'
|
import {ElMessage, ElForm} from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type {FormInstance, FormRules} from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import {useUserStore} from '@/stores/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -146,38 +146,38 @@ const validateAgreement = (rule: any, value: any, callback: any) => {
|
|||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
{required: true, message: '请输入用户名', trigger: 'blur'},
|
||||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
{min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur'},
|
||||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
|
{pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur'}
|
||||||
],
|
],
|
||||||
email: [
|
email: [
|
||||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
{required: true, message: '请输入邮箱', trigger: 'blur'},
|
||||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
{type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur'}
|
||||||
],
|
],
|
||||||
phone: [
|
phone: [
|
||||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur'}
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{required: true, message: '请输入密码', trigger: 'blur'},
|
||||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
{min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur'}
|
||||||
],
|
],
|
||||||
confirmPassword: [
|
confirmPassword: [
|
||||||
{ required: true, validator: validatePassword, trigger: 'blur' }
|
{required: true, validator: validatePassword, trigger: 'blur'}
|
||||||
],
|
],
|
||||||
agreement: [
|
agreement: [
|
||||||
{ required: true, validator: validateAgreement, trigger: 'change' }
|
{required: true, validator: validateAgreement, trigger: 'change'}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册
|
// 注册
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
|
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { confirmPassword, agreement, ...params } = registerForm
|
const {confirmPassword, agreement, ...params} = registerForm
|
||||||
const success = await userStore.register(params)
|
const success = await userStore.register(params)
|
||||||
if (success) {
|
if (success) {
|
||||||
// 注册成功,跳转到登录页已在store中处理
|
// 注册成功,跳转到登录页已在store中处理
|
||||||
@@ -190,7 +190,7 @@ const handleRegister = async () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.register-page {
|
.register-page {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-returns-page">
|
<div class="user-returns-page">
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>我的退货</el-breadcrumb-item>
|
<el-breadcrumb-item>我的退货</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
@@ -20,12 +20,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="returns.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
<div v-else-if="returns.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||||
<el-empty description="暂无退货记录" />
|
<el-empty description="暂无退货记录"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
@@ -40,13 +42,17 @@
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-20 h-20 rounded" img-class="w-20 h-20 object-cover rounded" />
|
<SafeImage :alt="item.productName" :src="item.productImage" img-class="w-20 h-20 object-cover rounded"
|
||||||
|
wrapper-class="w-20 h-20 rounded"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold">{{ item.productName || '商品' }}</h4>
|
<h4 class="font-semibold">{{ item.productName || '商品' }}</h4>
|
||||||
<div class="text-sm text-gray-500 mt-1">订单号:{{ item.orderNo }}</div>
|
<div class="text-sm text-gray-500 mt-1">订单号:{{ item.orderNo }}</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">退货原因:{{ item.reason }}</div>
|
<div class="text-sm text-gray-500 mt-1">退货原因:{{ item.reason }}</div>
|
||||||
<div v-if="item.rejectReason" class="text-sm text-red-500 mt-1">拒绝原因:{{ item.rejectReason }}</div>
|
<div v-if="item.rejectReason" class="text-sm text-red-500 mt-1">拒绝原因:{{ item.rejectReason }}</div>
|
||||||
<div v-if="item.returnTracking" class="text-sm text-gray-500 mt-1">物流单号:{{ item.returnTracking }}</div>
|
<div v-if="item.returnTracking" class="text-sm text-gray-500 mt-1">物流单号:{{
|
||||||
|
item.returnTracking
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-sm text-gray-500">退款金额</div>
|
<div class="text-sm text-gray-500">退款金额</div>
|
||||||
@@ -57,33 +63,39 @@
|
|||||||
|
|
||||||
<div class="border-t px-6 py-4 flex justify-end gap-2">
|
<div class="border-t px-6 py-4 flex justify-end gap-2">
|
||||||
<el-button text type="primary" @click="router.push(`/order/${item.orderId}`)">查看订单</el-button>
|
<el-button text type="primary" @click="router.push(`/order/${item.orderId}`)">查看订单</el-button>
|
||||||
<el-button v-if="item.status === 'APPROVED'" type="primary" size="small" @click="openTrackingDialog(item)">填写物流</el-button>
|
<el-button v-if="item.status === 'APPROVED'" size="small" type="primary" @click="openTrackingDialog(item)">
|
||||||
<el-button v-if="item.status === 'PENDING' || item.status === 'APPROVED'" size="small" @click="handleCancel(item)">取消退货</el-button>
|
填写物流
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="item.status === 'PENDING' || item.status === 'APPROVED'" size="small"
|
||||||
|
@click="handleCancel(item)">取消退货
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pagination.total > 0" class="mt-8 flex justify-center">
|
<div v-if="pagination.total > 0" class="mt-8 flex justify-center">
|
||||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadReturns" @current-change="loadReturns" />
|
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="pagination.total" layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="loadReturns" @current-change="loadReturns"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReturnTrackingDialog
|
<ReturnTrackingDialog
|
||||||
v-if="currentReturn"
|
v-if="currentReturn"
|
||||||
v-model:visible="trackingDialogVisible"
|
v-model:visible="trackingDialogVisible"
|
||||||
:return-id="currentReturn.id"
|
:return-id="currentReturn.id"
|
||||||
@success="loadReturns"
|
@success="loadReturns"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import {ref, reactive, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import { returnApi } from '@/api/modules/return'
|
import {returnApi} from '@/api/modules/return'
|
||||||
import { normalizeOrderReturn } from '@/utils/normalizers'
|
import {normalizeOrderReturn} from '@/utils/normalizers'
|
||||||
import type { OrderReturn } from '@/types/api'
|
import type {OrderReturn} from '@/types/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
|
import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue'
|
||||||
@@ -91,13 +103,20 @@ import ReturnTrackingDialog from '@/components/business/ReturnTrackingDialog.vue
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const returns = ref<OrderReturn[]>([])
|
const returns = ref<OrderReturn[]>([])
|
||||||
const filters = reactive({ status: '' })
|
const filters = reactive({status: ''})
|
||||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
const pagination = reactive({page: 1, size: 10, total: 0})
|
||||||
const trackingDialogVisible = ref(false)
|
const trackingDialogVisible = ref(false)
|
||||||
const currentReturn = ref<OrderReturn | null>(null)
|
const currentReturn = ref<OrderReturn | null>(null)
|
||||||
|
|
||||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||||
const getReturnStatusType = (status: string) => ({ PENDING: 'warning', APPROVED: 'primary', RETURNING: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info' }[status] || 'info')
|
const getReturnStatusType = (status: string) => ({
|
||||||
|
PENDING: 'warning',
|
||||||
|
APPROVED: 'primary',
|
||||||
|
RETURNING: 'primary',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
REJECTED: 'danger',
|
||||||
|
CANCELLED: 'info'
|
||||||
|
}[status] || 'info')
|
||||||
|
|
||||||
const loadReturns = async () => {
|
const loadReturns = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -125,7 +144,11 @@ const openTrackingDialog = (item: OrderReturn) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = async (item: OrderReturn) => {
|
const handleCancel = async (item: OrderReturn) => {
|
||||||
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm('确定要取消退货申请吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
await returnApi.cancel(item.id)
|
await returnApi.cancel(item.id)
|
||||||
ElMessage.success('退货申请已取消')
|
ElMessage.success('退货申请已取消')
|
||||||
@@ -135,10 +158,12 @@ const handleCancel = async (item: OrderReturn) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadReturns() })
|
onMounted(() => {
|
||||||
|
loadReturns()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.user-returns-page {
|
.user-returns-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-reviews-page">
|
<div class="user-reviews-page">
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<el-breadcrumb separator="/" class="mb-6">
|
<el-breadcrumb class="mb-6" separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>我的评价</el-breadcrumb-item>
|
<el-breadcrumb-item>我的评价</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
<h1 class="text-3xl font-bold mb-6">我的评价</h1>
|
<h1 class="text-3xl font-bold mb-6">我的评价</h1>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
<el-icon :size="40" class="animate-spin">
|
||||||
|
<Loading/>
|
||||||
|
</el-icon>
|
||||||
<p class="mt-2 text-gray-500">加载中...</p>
|
<p class="mt-2 text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,32 +25,32 @@
|
|||||||
<div v-for="review in paginatedReviews" :key="review.id" class="bg-white rounded-lg shadow-sm p-6">
|
<div v-for="review in paginatedReviews" :key="review.id" class="bg-white rounded-lg shadow-sm p-6">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
:src="review.productImage"
|
:alt="review.productName"
|
||||||
:alt="review.productName"
|
:src="review.productImage"
|
||||||
wrapper-class="w-20 h-20 rounded cursor-pointer"
|
img-class="w-20 h-20 object-cover rounded"
|
||||||
img-class="w-20 h-20 object-cover rounded"
|
wrapper-class="w-20 h-20 rounded cursor-pointer"
|
||||||
@click="router.push(`/product/${review.productId}`)"
|
@click="router.push(`/product/${review.productId}`)"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="font-semibold cursor-pointer hover:text-blue-500 inline"
|
class="font-semibold cursor-pointer hover:text-blue-500 inline"
|
||||||
@click="router.push(`/product/${review.productId}`)"
|
@click="router.push(`/product/${review.productId}`)"
|
||||||
>
|
>
|
||||||
{{ review.productName || '商品' }}
|
{{ review.productName || '商品' }}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
v-if="review.orderId"
|
v-if="review.orderId"
|
||||||
class="ml-3 text-xs text-gray-400 cursor-pointer hover:text-blue-400"
|
class="ml-3 text-xs text-gray-400 cursor-pointer hover:text-blue-400"
|
||||||
@click="router.push(`/order/${review.orderId}`)"
|
@click="router.push(`/order/${review.orderId}`)"
|
||||||
>
|
>
|
||||||
订单 #{{ review.orderId }}
|
订单 #{{ review.orderId }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</span>
|
<span class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-rate :model-value="review.rating" disabled />
|
<el-rate :model-value="review.rating" disabled/>
|
||||||
<p class="text-gray-600 mt-2 leading-6">{{ review.content }}</p>
|
<p class="text-gray-600 mt-2 leading-6">{{ review.content }}</p>
|
||||||
|
|
||||||
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm">
|
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm">
|
||||||
@@ -61,10 +63,10 @@
|
|||||||
|
|
||||||
<div v-if="reviews.length > pageSize" class="mt-8 flex justify-center">
|
<div v-if="reviews.length > pageSize" class="mt-8 flex justify-center">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="currentPage"
|
v-model:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:total="reviews.length"
|
:total="reviews.length"
|
||||||
layout="prev, pager, next"
|
layout="prev, pager, next"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,12 +74,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import {ElMessage} from 'element-plus'
|
||||||
import { reviewApi } from '@/api/modules/review'
|
import {reviewApi} from '@/api/modules/review'
|
||||||
import type { ReviewItem } from '@/api/modules/review'
|
import type {ReviewItem} from '@/api/modules/review'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SafeImage from '@/components/common/SafeImage.vue'
|
import SafeImage from '@/components/common/SafeImage.vue'
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.user-reviews-page {
|
.user-reviews-page {
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
64
community-fresh-group-buy-frontend/src/router/guards.ts
Normal file
64
community-fresh-group-buy-frontend/src/router/guards.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
if (userStore.token && !userStore.user) {
|
||||||
|
await userStore.getUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置页面标题
|
||||||
|
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.isAdmin) {
|
||||||
|
ElMessage.error('无权访问')
|
||||||
|
next('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminBlockedFrontPaths = [
|
||||||
|
'/cart',
|
||||||
|
'/orders',
|
||||||
|
'/favorites',
|
||||||
|
'/reviews',
|
||||||
|
'/returns',
|
||||||
|
'/notifications',
|
||||||
|
'/addresses',
|
||||||
|
'/profile',
|
||||||
|
]
|
||||||
|
const isAdminFrontPath = adminBlockedFrontPaths.some((path) => to.path === path || to.path.startsWith(`${path}/`))
|
||||||
|
if (userStore.isAdmin && isAdminFrontPath) {
|
||||||
|
next('/admin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录用户访问登录/注册页面
|
||||||
|
if ((to.path === '/login' || to.path === '/register') && userStore.isLoggedIn) {
|
||||||
|
next('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由后置守卫
|
||||||
|
router.afterEach((to, from) => {
|
||||||
|
// 页面切换后滚动到顶部
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
229
community-fresh-group-buy-frontend/src/router/index.ts
Normal file
229
community-fresh-group-buy-frontend/src/router/index.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
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: 'flashsales',
|
||||||
|
redirect: '/flashsale'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'search',
|
||||||
|
redirect: (to) => ({path: '/products', query: to.query})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'category/:category',
|
||||||
|
redirect: (to) => ({path: '/products', query: {category: String(to.params.category || '')}})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'favorites',
|
||||||
|
name: 'Favorites',
|
||||||
|
component: () => import('@/pages/user/favorites.vue'),
|
||||||
|
meta: {title: '我的收藏', requiresAuth: true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reviews',
|
||||||
|
name: 'MyReviews',
|
||||||
|
component: () => import('@/pages/user/reviews.vue'),
|
||||||
|
meta: {title: '我的评价', requiresAuth: true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'returns',
|
||||||
|
name: 'MyReturns',
|
||||||
|
component: () => import('@/pages/user/returns.vue'),
|
||||||
|
meta: {title: '我的退货', requiresAuth: true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
component: () => import('@/pages/user/notifications.vue'),
|
||||||
|
meta: {title: '消息通知', requiresAuth: true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'groupbuying',
|
||||||
|
name: 'GroupBuying',
|
||||||
|
component: () => import('@/pages/groupbuying/index.vue'),
|
||||||
|
meta: {title: '拼团活动'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'groupbuying/:id',
|
||||||
|
name: 'GroupBuyingDetail',
|
||||||
|
component: () => import('@/pages/groupbuying/detail.vue'),
|
||||||
|
meta: {title: '拼团详情'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'groupbuying/group/:id',
|
||||||
|
name: 'GroupBuyingGroupDetail',
|
||||||
|
component: () => import('@/pages/groupbuying/group.vue'),
|
||||||
|
meta: {title: '团组详情', requiresAuth: true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'addresses',
|
||||||
|
name: 'Addresses',
|
||||||
|
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: 'groupbuying',
|
||||||
|
name: 'AdminGroupBuying',
|
||||||
|
component: () => import('@/pages/admin/groupbuying.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: 'reviews',
|
||||||
|
name: 'AdminReviews',
|
||||||
|
component: () => import('@/pages/admin/reviews.vue'),
|
||||||
|
meta: {title: '评价管理'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'returns',
|
||||||
|
name: 'AdminReturns',
|
||||||
|
component: () => import('@/pages/admin/returns.vue'),
|
||||||
|
meta: {title: '退货管理'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'favorites',
|
||||||
|
name: 'AdminFavorites',
|
||||||
|
component: () => import('@/pages/admin/favorites.vue'),
|
||||||
|
meta: {title: '收藏管理'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'monitor',
|
||||||
|
name: 'AdminMonitor',
|
||||||
|
component: () => import('@/pages/admin/monitor.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
community-fresh-group-buy-frontend/src/stores/cart.ts
Normal file
167
community-fresh-group-buy-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,
|
||||||
|
}
|
||||||
|
})
|
||||||
159
community-fresh-group-buy-frontend/src/stores/user.ts
Normal file
159
community-fresh-group-buy-frontend/src/stores/user.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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' || user.value?.username === '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.setItem('token', token.value)
|
||||||
|
localStorage.setItem('user', JSON.stringify(user.value))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await userApi.getInfo()
|
||||||
|
if (profile.success) {
|
||||||
|
user.value = {
|
||||||
|
...profile.data,
|
||||||
|
avatar: profile.data.avatar || user.value?.avatar || '',
|
||||||
|
}
|
||||||
|
localStorage.setItem('user', JSON.stringify(user.value))
|
||||||
|
}
|
||||||
|
} catch (sessionError) {
|
||||||
|
console.error('登录成功但会话校验失败:', sessionError)
|
||||||
|
user.value = null
|
||||||
|
token.value = ''
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
ElMessage.error('登录成功但会话未建立,请检查 Cookie / 代理配置')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
const redirect = router.currentRoute.value.query.redirect as string
|
||||||
|
await 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 = async () => {
|
||||||
|
try {
|
||||||
|
if (token.value) {
|
||||||
|
await userApi.logout()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('退出登录失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
avatar: res.data.avatar || user.value?.avatar || '',
|
||||||
|
}
|
||||||
|
localStorage.setItem('user', JSON.stringify(user.value))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户信息失败:', error)
|
||||||
|
user.value = null
|
||||||
|
token.value = ''
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -73,13 +73,12 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', 'Hiragino Sans GB',
|
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', 'Hiragino Sans GB',
|
||||||
'Microsoft YaHei', sans-serif;
|
'Microsoft YaHei', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color: var(--tone-900);
|
color: var(--tone-900);
|
||||||
background:
|
background: radial-gradient(circle at top, rgba(255, 253, 248, 0.88), rgba(255, 253, 248, 0) 26%),
|
||||||
radial-gradient(circle at top, rgba(255, 253, 248, 0.88), rgba(255, 253, 248, 0) 26%),
|
linear-gradient(180deg, var(--tone-50) 0%, #f2ebdf 100%);
|
||||||
linear-gradient(180deg, var(--tone-50) 0%, #f2ebdf 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
165
community-fresh-group-buy-frontend/src/types/admin.ts
Normal file
165
community-fresh-group-buy-frontend/src/types/admin.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
export interface AdminDashboardStats {
|
||||||
|
totalUsers: number
|
||||||
|
totalProducts: number
|
||||||
|
totalOrders: number
|
||||||
|
totalAmount: number
|
||||||
|
todayOrders: number
|
||||||
|
paidOrders: number
|
||||||
|
pendingOrders: number
|
||||||
|
activeFlashSales: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserStats {
|
||||||
|
totalUsers: number
|
||||||
|
activeUsers: number
|
||||||
|
newUsers: number
|
||||||
|
onlineUsers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminOrderStats {
|
||||||
|
totalOrders: number
|
||||||
|
paidOrders: number
|
||||||
|
pendingOrders: number
|
||||||
|
completedOrders: number
|
||||||
|
cancelledOrders: number
|
||||||
|
totalAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductStats {
|
||||||
|
totalProducts: number
|
||||||
|
activeProducts: number
|
||||||
|
inactiveProducts: number
|
||||||
|
lowStockProducts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFlashSaleStats {
|
||||||
|
totalFlashSales: number
|
||||||
|
activeFlashSales: number
|
||||||
|
upcomingFlashSales: number
|
||||||
|
endedFlashSales: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRecentOrderRow {
|
||||||
|
id: number
|
||||||
|
orderNo: string
|
||||||
|
username: string
|
||||||
|
productName: string
|
||||||
|
quantity: number
|
||||||
|
totalAmount: number
|
||||||
|
status: string
|
||||||
|
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
|
||||||
|
createdAt: string
|
||||||
|
isFlashSale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminHotProductRow {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
stock: number
|
||||||
|
sales: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserRow {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
role: 'USER' | 'ADMIN'
|
||||||
|
isOnline: boolean
|
||||||
|
createdAt: string
|
||||||
|
lastLogin?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminOrderRow {
|
||||||
|
id: number
|
||||||
|
orderNo: string
|
||||||
|
username: string
|
||||||
|
productName: string
|
||||||
|
productId?: number
|
||||||
|
quantity: number
|
||||||
|
totalAmount: number
|
||||||
|
status: string
|
||||||
|
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
|
||||||
|
createdAt: string
|
||||||
|
isFlashSale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductRow {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
price: number
|
||||||
|
stock: number
|
||||||
|
status: number
|
||||||
|
imageUrl: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt?: string
|
||||||
|
totalSales?: number
|
||||||
|
totalRevenue?: number
|
||||||
|
viewCount?: number
|
||||||
|
rating?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorSystemStatus {
|
||||||
|
status: string
|
||||||
|
cpuUsage: number
|
||||||
|
memoryUsage: number
|
||||||
|
diskUsage: number
|
||||||
|
availableProcessors?: number
|
||||||
|
totalMemory?: string
|
||||||
|
usedMemory?: string
|
||||||
|
dbStatus?: string
|
||||||
|
redisStatus?: string
|
||||||
|
requestCountToday?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisNodeStatus {
|
||||||
|
node: string
|
||||||
|
status: string
|
||||||
|
memory: string
|
||||||
|
connections: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminReviewStats {
|
||||||
|
totalReviews: number
|
||||||
|
todayReviews: number
|
||||||
|
averageRating: number
|
||||||
|
fiveStarReviews: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFavoriteStats {
|
||||||
|
totalFavorites: number
|
||||||
|
favoriteUsers: number
|
||||||
|
favoriteProducts: number
|
||||||
|
todayFavorites: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminReviewRow {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
userId: number
|
||||||
|
orderId: number
|
||||||
|
productName: string
|
||||||
|
username: string
|
||||||
|
rating: number
|
||||||
|
content: string
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
adminReply?: string
|
||||||
|
repliedAt?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFavoriteRow {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productCategory: string
|
||||||
|
username: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
247
community-fresh-group-buy-frontend/src/types/api.d.ts
vendored
Normal file
247
community-fresh-group-buy-frontend/src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// 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' | 'PAUSED'
|
||||||
|
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' | 'REFUNDING' | 'REFUNDED'
|
||||||
|
orderType?: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退货类型
|
||||||
|
export interface OrderReturn {
|
||||||
|
id: number
|
||||||
|
returnNo: string
|
||||||
|
orderId: number
|
||||||
|
orderNo: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
refundAmount: number
|
||||||
|
reason: string
|
||||||
|
description?: string
|
||||||
|
images?: string
|
||||||
|
status: 'PENDING' | 'APPROVED' | 'RETURNING' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
|
||||||
|
statusText: string
|
||||||
|
rejectReason?: string
|
||||||
|
adminRemark?: string
|
||||||
|
returnTracking?: string
|
||||||
|
productName?: string
|
||||||
|
productImage?: string
|
||||||
|
reviewedAt?: string
|
||||||
|
shippedAt?: string
|
||||||
|
completedAt?: string
|
||||||
|
cancelledAt?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼团活动类型
|
||||||
|
export interface GroupBuying {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productImageUrl: string
|
||||||
|
productPrice: number
|
||||||
|
groupPrice: number
|
||||||
|
requiredMembers: number
|
||||||
|
durationMinutes: number
|
||||||
|
totalStock: number
|
||||||
|
remainingStock: number
|
||||||
|
maxPerUser: number
|
||||||
|
status: 'DRAFT' | 'UPCOMING' | 'ACTIVE' | 'ENDED'
|
||||||
|
statusDescription: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
activeGroupCount: number
|
||||||
|
discount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼团团组类型
|
||||||
|
export interface GroupBuyingGroup {
|
||||||
|
id: number
|
||||||
|
groupNo: string
|
||||||
|
groupBuyingId: number
|
||||||
|
leaderUserId: number
|
||||||
|
leaderUsername: string
|
||||||
|
requiredMembers: number
|
||||||
|
currentMembers: number
|
||||||
|
status: 'FORMING' | 'SUCCESS' | 'FAILED'
|
||||||
|
statusDescription: string
|
||||||
|
expireTime: string
|
||||||
|
createdAt: string
|
||||||
|
completedAt?: string
|
||||||
|
members: GroupBuyingMember[]
|
||||||
|
groupBuying?: GroupBuying
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼团成员类型
|
||||||
|
export interface GroupBuyingMember {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
avatar?: string
|
||||||
|
orderId?: number
|
||||||
|
status: number
|
||||||
|
joinedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼团统计
|
||||||
|
export interface GroupBuyingStatistics {
|
||||||
|
totalActivities: number
|
||||||
|
activeActivities: number
|
||||||
|
myGroups: number
|
||||||
|
successGroups: number
|
||||||
|
totalSaved: number
|
||||||
|
}
|
||||||
29
community-fresh-group-buy-frontend/src/types/flashsale.ts
Normal file
29
community-fresh-group-buy-frontend/src/types/flashsale.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface FlashSale {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productImage: string
|
||||||
|
originalPrice: number
|
||||||
|
flashPrice: number
|
||||||
|
flashStock: number
|
||||||
|
soldCount: number
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
status: number // 0-未开始 1-进行中 2-已结束
|
||||||
|
description?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlashSaleParams {
|
||||||
|
status?: number
|
||||||
|
keyword?: string
|
||||||
|
sort?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlashSaleParticipation {
|
||||||
|
flashSaleId: number
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
37
community-fresh-group-buy-frontend/src/types/product.ts
Normal file
37
community-fresh-group-buy-frontend/src/types/product.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
price: number
|
||||||
|
stock: number
|
||||||
|
image: string
|
||||||
|
category: string
|
||||||
|
categoryId: number
|
||||||
|
brand?: string
|
||||||
|
specifications?: Record<string, any>
|
||||||
|
sales: number
|
||||||
|
rating: number
|
||||||
|
reviewCount: number
|
||||||
|
status: number // 0-下架 1-上架
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductParams {
|
||||||
|
categoryId?: number
|
||||||
|
keyword?: string
|
||||||
|
minPrice?: number
|
||||||
|
maxPrice?: number
|
||||||
|
sort?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
parentId?: number
|
||||||
|
icon?: string
|
||||||
|
sort: number
|
||||||
|
children?: Category[]
|
||||||
|
}
|
||||||
66
community-fresh-group-buy-frontend/src/utils/image.ts
Normal file
66
community-fresh-group-buy-frontend/src/utils/image.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import defaultProductImage from '@/assets/default-product.svg'
|
||||||
|
|
||||||
|
export const DEFAULT_PRODUCT_IMAGE = defaultProductImage
|
||||||
|
|
||||||
|
const ABSOLUTE_URL_PATTERN = /^(https?:)?\/\//i
|
||||||
|
const SPECIAL_URL_PATTERN = /^(data:|blob:)/i
|
||||||
|
|
||||||
|
const normalizeBaseUrl = (value?: string) => {
|
||||||
|
if (!value) return ''
|
||||||
|
return value.endsWith('/') ? value.slice(0, -1) : value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveImageUrl = (value?: string | null) => {
|
||||||
|
if (!value || !String(value).trim()) {
|
||||||
|
return DEFAULT_PRODUCT_IMAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = String(value).trim()
|
||||||
|
if (ABSOLUTE_URL_PATTERN.test(imageUrl) || SPECIAL_URL_PATTERN.test(imageUrl)) {
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||||
|
if (!baseUrl) {
|
||||||
|
return imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeStorageImageUrl = (value?: string | null) => {
|
||||||
|
if (!value || !String(value).trim()) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = String(value).trim()
|
||||||
|
if (ABSOLUTE_URL_PATTERN.test(imageUrl)) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(imageUrl.startsWith('//') ? `http:${imageUrl}` : imageUrl)
|
||||||
|
if (
|
||||||
|
parsed.pathname.startsWith('/uploads/') ||
|
||||||
|
parsed.pathname.startsWith('/images/') ||
|
||||||
|
parsed.pathname.startsWith('/static/')
|
||||||
|
) {
|
||||||
|
return parsed.pathname
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyFallbackImage = (event: Event) => {
|
||||||
|
const target = event.target as HTMLImageElement | null
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
if (target.dataset.fallbackApplied === 'true') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.dataset.fallbackApplied = 'true'
|
||||||
|
target.onerror = null
|
||||||
|
target.src = DEFAULT_PRODUCT_IMAGE
|
||||||
|
}
|
||||||
407
community-fresh-group-buy-frontend/src/utils/normalizers.ts
Normal file
407
community-fresh-group-buy-frontend/src/utils/normalizers.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import type {
|
||||||
|
CartItem,
|
||||||
|
FlashSale,
|
||||||
|
GroupBuying,
|
||||||
|
GroupBuyingGroup,
|
||||||
|
Order,
|
||||||
|
OrderAddress,
|
||||||
|
OrderReturn,
|
||||||
|
PageResponse,
|
||||||
|
Product,
|
||||||
|
User,
|
||||||
|
} from '@/types/api'
|
||||||
|
import type {
|
||||||
|
AdminHotProductRow,
|
||||||
|
AdminOrderRow,
|
||||||
|
AdminProductRow,
|
||||||
|
AdminRecentOrderRow,
|
||||||
|
AdminUserRow,
|
||||||
|
} from '@/types/admin'
|
||||||
|
import {DEFAULT_PRODUCT_IMAGE, normalizeStorageImageUrl, resolveImageUrl} from '@/utils/image'
|
||||||
|
|
||||||
|
const toNumber = (value: unknown, fallback = 0) => {
|
||||||
|
const result = Number(value)
|
||||||
|
return Number.isFinite(result) ? result : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const toString = (value: unknown, fallback = '') => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toIsoLikeString = (value: unknown) => {
|
||||||
|
const raw = toString(value)
|
||||||
|
return raw || new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildOrderNo = (id: number | string) => {
|
||||||
|
const numericId = toString(id).padStart(6, '0')
|
||||||
|
return `FS${numericId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapUserStatusText = (status: number) => {
|
||||||
|
return status === 1 ? '正常' : '禁用'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapOrderStatus = (status: number | string): Order['status'] => {
|
||||||
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
|
if (value === 'PENDING' || value === 1) return 'PENDING'
|
||||||
|
if (value === 'PAID' || value === 2) return 'PAID'
|
||||||
|
if (value === 'SHIPPED' || value === 3) return 'SHIPPED'
|
||||||
|
if (value === 'COMPLETED' || value === 4) return 'COMPLETED'
|
||||||
|
if (value === 'CANCELLED' || value === 5) return 'CANCELLED'
|
||||||
|
if (value === 'REFUNDING' || value === 6) return 'REFUNDING'
|
||||||
|
if (value === 'REFUNDED' || value === 7) return 'REFUNDED'
|
||||||
|
return 'PENDING'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapOrderType = (orderType: number | string | undefined, isFlashSale?: boolean): 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING' => {
|
||||||
|
const value = typeof orderType === 'string' ? orderType : toNumber(orderType)
|
||||||
|
if (value === 'FLASH_SALE' || value === 2) return 'FLASH_SALE'
|
||||||
|
if (value === 'GROUP_BUYING' || value === 3) return 'GROUP_BUYING'
|
||||||
|
if (isFlashSale) return 'FLASH_SALE'
|
||||||
|
return 'NORMAL'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapFlashSaleStatus = (status: number | string): FlashSale['status'] => {
|
||||||
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
|
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
|
||||||
|
if (value === 'ACTIVE' || value === 2) return 'ACTIVE'
|
||||||
|
if (value === 'ENDED' || value === 3) return 'ENDED'
|
||||||
|
if (value === 'PAUSED' || value === 4) return 'PAUSED'
|
||||||
|
return 'UPCOMING'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapProductStatus = (status: number | string, stock = 0): Product['status'] => {
|
||||||
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
|
if (stock <= 0) return 'SOLD_OUT'
|
||||||
|
if (value === 'OFF_SALE' || value === 0) return 'OFF_SALE'
|
||||||
|
return 'ON_SALE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeUser = (user: Record<string, any>): User => {
|
||||||
|
const username = toString(user.username)
|
||||||
|
return {
|
||||||
|
id: toNumber(user.id),
|
||||||
|
username,
|
||||||
|
email: toString(user.email),
|
||||||
|
phone: toString(user.phone),
|
||||||
|
avatar: resolveImageUrl(toString(user.avatar, '')),
|
||||||
|
role: toString(user.role).toUpperCase() === 'ADMIN' ? 'ADMIN' : username === 'admin' ? 'ADMIN' : 'USER',
|
||||||
|
status: toNumber(user.status, 1) === 1 ? 'ACTIVE' : 'BANNED',
|
||||||
|
createdAt: toIsoLikeString(user.createdAt),
|
||||||
|
updatedAt: toIsoLikeString(user.updatedAt || user.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeProduct = (product: Record<string, any>): Product => {
|
||||||
|
const stock = toNumber(product.stock)
|
||||||
|
const imageUrl = resolveImageUrl(toString(product.imageUrl, ''))
|
||||||
|
return {
|
||||||
|
id: toNumber(product.id),
|
||||||
|
name: toString(product.name),
|
||||||
|
description: toString(product.description),
|
||||||
|
price: toNumber(product.price),
|
||||||
|
stock,
|
||||||
|
imageUrl,
|
||||||
|
images: imageUrl ? [imageUrl] : [DEFAULT_PRODUCT_IMAGE],
|
||||||
|
category: toString(product.category, '默认分类'),
|
||||||
|
status: mapProductStatus(product.status, stock),
|
||||||
|
sales: toNumber(product.sales),
|
||||||
|
views: toNumber(product.viewCount ?? product.views),
|
||||||
|
createdAt: toIsoLikeString(product.createdAt),
|
||||||
|
updatedAt: toIsoLikeString(product.updatedAt || product.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeFlashSale = (flashSale: Record<string, any>): FlashSale => {
|
||||||
|
const flashStock = toNumber(flashSale.flashStock)
|
||||||
|
const remainingStock = toNumber(flashSale.remainingStock, flashStock)
|
||||||
|
return {
|
||||||
|
id: toNumber(flashSale.id),
|
||||||
|
productId: toNumber(flashSale.productId),
|
||||||
|
productName: toString(flashSale.productName),
|
||||||
|
productImageUrl: resolveImageUrl(toString(flashSale.productImageUrl, '')),
|
||||||
|
originalPrice: toNumber(flashSale.originalPrice),
|
||||||
|
flashPrice: toNumber(flashSale.flashPrice),
|
||||||
|
flashStock,
|
||||||
|
remainingStock,
|
||||||
|
startTime: toIsoLikeString(flashSale.startTime),
|
||||||
|
endTime: toIsoLikeString(flashSale.endTime),
|
||||||
|
status: mapFlashSaleStatus(flashSale.status),
|
||||||
|
limitPerUser: toNumber(flashSale.limitPerUser, 1),
|
||||||
|
description: toString(flashSale.description || flashSale.statusDescription),
|
||||||
|
createdAt: toIsoLikeString(flashSale.createdAt),
|
||||||
|
updatedAt: toIsoLikeString(flashSale.updatedAt || flashSale.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildOrderAddress = (order: Record<string, any>): OrderAddress | undefined => {
|
||||||
|
const name = toString(order.receiverName)
|
||||||
|
const phone = toString(order.receiverPhone)
|
||||||
|
const address = toString(order.receiverAddress)
|
||||||
|
if (!name && !phone && !address) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
district: '',
|
||||||
|
address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeOrder = (order: Record<string, any>): Order => {
|
||||||
|
const totalAmount = toNumber(order.totalAmount ?? order.totalPrice)
|
||||||
|
const quantity = toNumber(order.quantity, 1)
|
||||||
|
const status = mapOrderStatus(order.status)
|
||||||
|
const createdAt = toIsoLikeString(order.createdAt)
|
||||||
|
const updatedAt = toIsoLikeString(order.updatedAt || order.createdAt)
|
||||||
|
const productImage = resolveImageUrl(toString(order.productImageUrl, ''))
|
||||||
|
|
||||||
|
const fallbackItem = {
|
||||||
|
id: toNumber(order.productId || order.id),
|
||||||
|
productId: toNumber(order.productId),
|
||||||
|
productName: toString(order.productName, '未知商品'),
|
||||||
|
productImage,
|
||||||
|
price: quantity > 0 ? Number((totalAmount / quantity).toFixed(2)) : totalAmount,
|
||||||
|
quantity,
|
||||||
|
subtotal: totalAmount,
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(order.items) && order.items.length > 0
|
||||||
|
? order.items.map((item: Record<string, any>) => ({
|
||||||
|
id: toNumber(item.id || item.productId),
|
||||||
|
productId: toNumber(item.productId),
|
||||||
|
productName: toString(item.productName, '未知商品'),
|
||||||
|
productImage: resolveImageUrl(toString(item.productImageUrl || item.productImage, '')),
|
||||||
|
price: toNumber(item.price),
|
||||||
|
quantity: toNumber(item.quantity, 1),
|
||||||
|
subtotal: toNumber(item.subtotal ?? item.price),
|
||||||
|
}))
|
||||||
|
: [fallbackItem]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: toNumber(order.id),
|
||||||
|
orderNo: toString(order.orderNo, buildOrderNo(order.id)),
|
||||||
|
userId: toNumber(order.userId),
|
||||||
|
username: toString(order.username),
|
||||||
|
totalAmount,
|
||||||
|
paymentAmount: totalAmount,
|
||||||
|
paymentMethod: toString(order.paymentMethod) || (status === 'PENDING' ? undefined : 'ONLINE'),
|
||||||
|
status,
|
||||||
|
orderType: mapOrderType(order.orderType, order.isFlashSale),
|
||||||
|
items,
|
||||||
|
address: buildOrderAddress(order),
|
||||||
|
remark: toString(order.remark),
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
paidAt: order.paidAt ? toIsoLikeString(order.paidAt) : (status === 'PAID' || status === 'SHIPPED' || status === 'COMPLETED' ? updatedAt : undefined),
|
||||||
|
shippedAt: order.shippedAt ? toIsoLikeString(order.shippedAt) : (status === 'SHIPPED' || status === 'COMPLETED' ? updatedAt : undefined),
|
||||||
|
completedAt: order.completedAt ? toIsoLikeString(order.completedAt) : (status === 'COMPLETED' ? updatedAt : undefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeCartItems = (cart: Record<string, any> | undefined): CartItem[] => {
|
||||||
|
const items = Array.isArray(cart?.items) ? cart.items : []
|
||||||
|
return items.map((item: Record<string, any>) => ({
|
||||||
|
id: toString(item.productId),
|
||||||
|
productId: toNumber(item.productId),
|
||||||
|
productName: toString(item.productName),
|
||||||
|
productImage: resolveImageUrl(toString(item.productImageUrl || item.productImage, '')),
|
||||||
|
price: toNumber(item.productPrice),
|
||||||
|
quantity: toNumber(item.quantity, 1),
|
||||||
|
stock: toNumber(item.stock),
|
||||||
|
selected: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizePage = <T>(payload: Record<string, any>, mapper: (item: Record<string, any>) => T): PageResponse<T> => {
|
||||||
|
const content = Array.isArray(payload.content) ? payload.content.map((item: Record<string, any>) => mapper(item)) : []
|
||||||
|
const size = toNumber(payload.size, content.length || 10)
|
||||||
|
const pageNumber = toNumber(payload.currentPage ?? payload.number)
|
||||||
|
const totalElements = toNumber(payload.totalElements, content.length)
|
||||||
|
const totalPages = toNumber(payload.totalPages, size > 0 ? Math.ceil(totalElements / size) : 1)
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
totalElements,
|
||||||
|
totalPages,
|
||||||
|
size,
|
||||||
|
number: pageNumber,
|
||||||
|
first: pageNumber <= 0,
|
||||||
|
last: totalPages === 0 ? true : pageNumber >= totalPages - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeAdminRecentOrder = (order: Record<string, any>): AdminRecentOrderRow => ({
|
||||||
|
id: toNumber(order.id),
|
||||||
|
orderNo: buildOrderNo(order.id),
|
||||||
|
username: toString(order.username),
|
||||||
|
productName: toString(order.productName),
|
||||||
|
quantity: toNumber(order.quantity, 1),
|
||||||
|
totalAmount: toNumber(order.totalAmount ?? order.totalPrice),
|
||||||
|
status: mapOrderStatus(order.status),
|
||||||
|
orderType: mapOrderType(order.orderType, order.isFlashSale),
|
||||||
|
createdAt: toIsoLikeString(order.createdAt),
|
||||||
|
isFlashSale: Boolean(order.isFlashSale),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAdminHotProduct = (product: Record<string, any>): AdminHotProductRow => ({
|
||||||
|
id: toNumber(product.id),
|
||||||
|
name: toString(product.name),
|
||||||
|
price: toNumber(product.price),
|
||||||
|
stock: toNumber(product.stock),
|
||||||
|
sales: toNumber(product.sales),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAdminUser = (user: Record<string, any>): AdminUserRow => ({
|
||||||
|
id: toNumber(user.id),
|
||||||
|
username: toString(user.username),
|
||||||
|
email: toString(user.email),
|
||||||
|
phone: toString(user.phone),
|
||||||
|
status: toNumber(user.status, 1),
|
||||||
|
statusText: mapUserStatusText(toNumber(user.status, 1)),
|
||||||
|
role: toString(user.role).toUpperCase() === 'ADMIN' || toString(user.username) === 'admin' ? 'ADMIN' : 'USER',
|
||||||
|
isOnline: Boolean(user.isOnline),
|
||||||
|
createdAt: toIsoLikeString(user.createdAt),
|
||||||
|
lastLogin: user.lastLogin ? toIsoLikeString(user.lastLogin) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAdminOrder = (order: Record<string, any>): AdminOrderRow => ({
|
||||||
|
id: toNumber(order.id),
|
||||||
|
orderNo: buildOrderNo(order.id),
|
||||||
|
username: toString(order.username),
|
||||||
|
productName: toString(order.productName),
|
||||||
|
productId: toNumber(order.productId),
|
||||||
|
quantity: toNumber(order.quantity, 1),
|
||||||
|
totalAmount: toNumber(order.totalAmount),
|
||||||
|
status: mapOrderStatus(order.status),
|
||||||
|
orderType: mapOrderType(order.orderType, order.isFlashSale),
|
||||||
|
createdAt: toIsoLikeString(order.createdAt),
|
||||||
|
isFlashSale: Boolean(order.isFlashSale),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAdminProduct = (product: Record<string, any>): AdminProductRow => ({
|
||||||
|
id: toNumber(product.id),
|
||||||
|
name: toString(product.name),
|
||||||
|
description: toString(product.description),
|
||||||
|
category: toString(product.category, '默认分类'),
|
||||||
|
price: toNumber(product.price),
|
||||||
|
stock: toNumber(product.stock),
|
||||||
|
status: toNumber(product.status, 1),
|
||||||
|
imageUrl: normalizeStorageImageUrl(toString(product.imageUrl, '')),
|
||||||
|
createdAt: toIsoLikeString(product.createdAt),
|
||||||
|
updatedAt: product.updatedAt ? toIsoLikeString(product.updatedAt) : undefined,
|
||||||
|
totalSales: toNumber(product.totalSales),
|
||||||
|
totalRevenue: toNumber(product.totalRevenue),
|
||||||
|
viewCount: toNumber(product.viewCount),
|
||||||
|
rating: toNumber(product.rating),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const mapGroupBuyingStatus = (status: number | string): GroupBuying['status'] => {
|
||||||
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
|
if (value === 'DRAFT' || value === 0) return 'DRAFT'
|
||||||
|
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
|
||||||
|
if (value === 'ACTIVE' || value === 2) return 'ACTIVE'
|
||||||
|
if (value === 'ENDED' || value === 3) return 'ENDED'
|
||||||
|
return 'DRAFT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapGroupStatus = (status: number | string): GroupBuyingGroup['status'] => {
|
||||||
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
|
if (value === 'FORMING' || value === 1) return 'FORMING'
|
||||||
|
if (value === 'SUCCESS' || value === 2) return 'SUCCESS'
|
||||||
|
if (value === 'FAILED' || value === 3) return 'FAILED'
|
||||||
|
return 'FORMING'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeGroupBuying = (gb: Record<string, any>): GroupBuying => ({
|
||||||
|
id: toNumber(gb.id),
|
||||||
|
productId: toNumber(gb.productId),
|
||||||
|
productName: toString(gb.productName),
|
||||||
|
productImageUrl: resolveImageUrl(toString(gb.productImageUrl, '')),
|
||||||
|
productPrice: toNumber(gb.productPrice),
|
||||||
|
groupPrice: toNumber(gb.groupPrice),
|
||||||
|
requiredMembers: toNumber(gb.requiredMembers, 2),
|
||||||
|
durationMinutes: toNumber(gb.durationMinutes, 1440),
|
||||||
|
totalStock: toNumber(gb.totalStock),
|
||||||
|
remainingStock: toNumber(gb.remainingStock),
|
||||||
|
maxPerUser: toNumber(gb.maxPerUser, 1),
|
||||||
|
status: mapGroupBuyingStatus(gb.status),
|
||||||
|
statusDescription: toString(gb.statusDescription),
|
||||||
|
startTime: toIsoLikeString(gb.startTime),
|
||||||
|
endTime: toIsoLikeString(gb.endTime),
|
||||||
|
createdAt: toIsoLikeString(gb.createdAt),
|
||||||
|
updatedAt: toIsoLikeString(gb.updatedAt || gb.createdAt),
|
||||||
|
activeGroupCount: toNumber(gb.activeGroupCount),
|
||||||
|
discount: toNumber(gb.discount),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const mapReturnStatus = (status: number | string): OrderReturn['status'] => {
|
||||||
|
const value = typeof status === 'string' ? status : toNumber(status)
|
||||||
|
if (value === 'PENDING' || value === 1) return 'PENDING'
|
||||||
|
if (value === 'APPROVED' || value === 2) return 'APPROVED'
|
||||||
|
if (value === 'RETURNING' || value === 3) return 'RETURNING'
|
||||||
|
if (value === 'COMPLETED' || value === 4) return 'COMPLETED'
|
||||||
|
if (value === 'REJECTED' || value === 5) return 'REJECTED'
|
||||||
|
if (value === 'CANCELLED' || value === 6) return 'CANCELLED'
|
||||||
|
return 'PENDING'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeOrderReturn = (ret: Record<string, any>): OrderReturn => ({
|
||||||
|
id: toNumber(ret.id),
|
||||||
|
returnNo: toString(ret.returnNo),
|
||||||
|
orderId: toNumber(ret.orderId),
|
||||||
|
orderNo: toString(ret.orderNo),
|
||||||
|
userId: toNumber(ret.userId),
|
||||||
|
username: toString(ret.username),
|
||||||
|
refundAmount: toNumber(ret.refundAmount),
|
||||||
|
reason: toString(ret.reason),
|
||||||
|
description: toString(ret.description),
|
||||||
|
images: toString(ret.images),
|
||||||
|
status: mapReturnStatus(ret.status),
|
||||||
|
statusText: toString(ret.statusText),
|
||||||
|
rejectReason: toString(ret.rejectReason),
|
||||||
|
adminRemark: toString(ret.adminRemark),
|
||||||
|
returnTracking: toString(ret.returnTracking),
|
||||||
|
productName: toString(ret.productName),
|
||||||
|
productImage: resolveImageUrl(toString(ret.productImage, '')),
|
||||||
|
reviewedAt: ret.reviewedAt ? toIsoLikeString(ret.reviewedAt) : undefined,
|
||||||
|
shippedAt: ret.shippedAt ? toIsoLikeString(ret.shippedAt) : undefined,
|
||||||
|
completedAt: ret.completedAt ? toIsoLikeString(ret.completedAt) : undefined,
|
||||||
|
cancelledAt: ret.cancelledAt ? toIsoLikeString(ret.cancelledAt) : undefined,
|
||||||
|
createdAt: toIsoLikeString(ret.createdAt),
|
||||||
|
updatedAt: toIsoLikeString(ret.updatedAt || ret.createdAt),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeGroupBuyingGroup = (group: Record<string, any>): GroupBuyingGroup => ({
|
||||||
|
id: toNumber(group.id),
|
||||||
|
groupNo: toString(group.groupNo),
|
||||||
|
groupBuyingId: toNumber(group.groupBuyingId),
|
||||||
|
leaderUserId: toNumber(group.leaderUserId),
|
||||||
|
leaderUsername: toString(group.leaderUsername),
|
||||||
|
requiredMembers: toNumber(group.requiredMembers, 2),
|
||||||
|
currentMembers: toNumber(group.currentMembers, 1),
|
||||||
|
status: mapGroupStatus(group.status),
|
||||||
|
statusDescription: toString(group.statusDescription),
|
||||||
|
expireTime: toIsoLikeString(group.expireTime),
|
||||||
|
createdAt: toIsoLikeString(group.createdAt),
|
||||||
|
completedAt: group.completedAt ? toIsoLikeString(group.completedAt) : undefined,
|
||||||
|
members: Array.isArray(group.members)
|
||||||
|
? group.members.map((m: Record<string, any>) => ({
|
||||||
|
id: toNumber(m.id),
|
||||||
|
userId: toNumber(m.userId),
|
||||||
|
username: toString(m.username),
|
||||||
|
avatar: resolveImageUrl(toString(m.avatar, '')),
|
||||||
|
orderId: m.orderId ? toNumber(m.orderId) : undefined,
|
||||||
|
status: toNumber(m.status),
|
||||||
|
joinedAt: toIsoLikeString(m.joinedAt),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
groupBuying: group.groupBuying ? normalizeGroupBuying(group.groupBuying) : undefined,
|
||||||
|
})
|
||||||
29
community-fresh-group-buy-frontend/tailwind.config.js
Normal file
29
community-fresh-group-buy-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: '#f7f7f6',
|
||||||
|
100: '#efefed',
|
||||||
|
200: '#dfdfdc',
|
||||||
|
300: '#c6c6c2',
|
||||||
|
400: '#9f9f99',
|
||||||
|
500: '#7b7b74',
|
||||||
|
600: '#5e5e58',
|
||||||
|
700: '#44443f',
|
||||||
|
800: '#2b2b27',
|
||||||
|
900: '#171715',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
@@ -17,10 +21,25 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"types": ["vite/client", "element-plus/global", "node"]
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"element-plus/global",
|
||||||
|
"node"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": [
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
53
community-fresh-group-buy-frontend/vite.config.ts
Normal file
53
community-fresh-group-buy-frontend/vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {defineConfig} from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (proxyPath) => proxyPath.replace(/^\/api/, '/api'),
|
||||||
|
},
|
||||||
|
'/images': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/static': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: 'modern-compiler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!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="社区生鲜团购系统,支持分布式锁、接口限流、库存预热等核心功能">
|
|
||||||
<meta content="限时,抢购,电商,community group buying" name="keywords">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import request from './request'
|
|
||||||
import type { FlashSale, FlashSaleParams } from '@/types/flashsale'
|
|
||||||
|
|
||||||
export const flashSaleApi = {
|
|
||||||
// 获取限时活动列表
|
|
||||||
getList(params?: FlashSaleParams) {
|
|
||||||
return request.get<any, { list: FlashSale[], total: number }>('/api/flashsales', { params })
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取限时活动详情
|
|
||||||
getDetail(id: number) {
|
|
||||||
return request.get<any, FlashSale>(`/api/flashsales/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 参与限时
|
|
||||||
participate(flashSaleId: number, quantity: number = 1) {
|
|
||||||
return request.post('/api/flashsales/participate', {
|
|
||||||
flashSaleId,
|
|
||||||
quantity
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取正在进行的限时活动
|
|
||||||
getActive() {
|
|
||||||
return request.get<any, FlashSale[]>('/api/flashsales/active')
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取即将开始的限时活动
|
|
||||||
getUpcoming() {
|
|
||||||
return request.get<any, FlashSale[]>('/api/flashsales/upcoming')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default flashSaleApi
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse } from '@/types/api'
|
|
||||||
|
|
||||||
export interface AddressItem {
|
|
||||||
id: number
|
|
||||||
userId?: number
|
|
||||||
name: string
|
|
||||||
phone: string
|
|
||||||
province: string
|
|
||||||
city: string
|
|
||||||
district: string
|
|
||||||
address: string
|
|
||||||
isDefault: boolean
|
|
||||||
createdAt?: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveAddressParams {
|
|
||||||
name: string
|
|
||||||
phone: string
|
|
||||||
province: string
|
|
||||||
city: string
|
|
||||||
district: string
|
|
||||||
address: string
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addressApi = {
|
|
||||||
getList(): Promise<ApiResponse<AddressItem[]>> {
|
|
||||||
return request.get('/api/address')
|
|
||||||
},
|
|
||||||
|
|
||||||
getDefault(): Promise<ApiResponse<AddressItem>> {
|
|
||||||
return request.get('/api/address/default')
|
|
||||||
},
|
|
||||||
|
|
||||||
create(data: SaveAddressParams): Promise<ApiResponse<AddressItem>> {
|
|
||||||
return request.post('/api/address', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
update(id: number, data: SaveAddressParams): Promise<ApiResponse<AddressItem>> {
|
|
||||||
return request.put(`/api/address/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
setDefault(id: number): Promise<ApiResponse<AddressItem>> {
|
|
||||||
return request.post(`/api/address/${id}/default`)
|
|
||||||
},
|
|
||||||
|
|
||||||
delete(id: number): Promise<ApiResponse> {
|
|
||||||
return request.delete(`/api/address/${id}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse } from '@/types/api'
|
|
||||||
import type {
|
|
||||||
AdminDashboardStats,
|
|
||||||
AdminFavoriteRow,
|
|
||||||
AdminFavoriteStats,
|
|
||||||
AdminFlashSaleStats,
|
|
||||||
AdminHotProductRow,
|
|
||||||
AdminOrderRow,
|
|
||||||
AdminOrderStats,
|
|
||||||
AdminProductRow,
|
|
||||||
AdminProductStats,
|
|
||||||
AdminRecentOrderRow,
|
|
||||||
AdminReviewRow,
|
|
||||||
AdminReviewStats,
|
|
||||||
AdminUserRow,
|
|
||||||
AdminUserStats,
|
|
||||||
MonitorSystemStatus,
|
|
||||||
RedisNodeStatus,
|
|
||||||
} from '@/types/admin'
|
|
||||||
import {
|
|
||||||
normalizeAdminHotProduct,
|
|
||||||
normalizeAdminOrder,
|
|
||||||
normalizeAdminProduct,
|
|
||||||
normalizeAdminRecentOrder,
|
|
||||||
normalizeAdminUser,
|
|
||||||
} from '@/utils/normalizers'
|
|
||||||
|
|
||||||
export const adminApi = {
|
|
||||||
getDashboardStats(): Promise<ApiResponse<AdminDashboardStats>> {
|
|
||||||
return request.get('/api/admin/dashboard/stats')
|
|
||||||
},
|
|
||||||
getUserStats(): Promise<ApiResponse<AdminUserStats>> {
|
|
||||||
return request.get('/api/admin/users/stats')
|
|
||||||
},
|
|
||||||
getOrderStats(): Promise<ApiResponse<AdminOrderStats>> {
|
|
||||||
return request.get('/api/admin/orders/stats')
|
|
||||||
},
|
|
||||||
getProductStats(): Promise<ApiResponse<AdminProductStats>> {
|
|
||||||
return request.get('/api/admin/products/stats')
|
|
||||||
},
|
|
||||||
getFlashSaleStats(): Promise<ApiResponse<AdminFlashSaleStats>> {
|
|
||||||
return request.get('/api/admin/flashsales/stats')
|
|
||||||
},
|
|
||||||
getRecentOrders(limit = 10): Promise<ApiResponse<AdminRecentOrderRow[]>> {
|
|
||||||
return request.get<ApiResponse<any[]>>('/api/admin/orders/recent', { limit }).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminRecentOrder(item)) : [],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
getHotProducts(limit = 5): Promise<ApiResponse<AdminHotProductRow[]>> {
|
|
||||||
return request.get<ApiResponse<any[]>>('/api/admin/products/hot', { limit }).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminHotProduct(item)) : [],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
getUsers(params: { page: number; size: number; keyword?: string; status?: number | '' }): Promise<ApiResponse<{ users: AdminUserRow[]; total: number; totalPages: number; currentPage: number; size: number }>> {
|
|
||||||
const query = { page: params.page, size: params.size, keyword: params.keyword, status: params.status === '' ? undefined : params.status }
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/admin/users', query).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
users: Array.isArray(res.data.users) ? res.data.users.map((item) => normalizeAdminUser(item)) : [],
|
|
||||||
total: Number(res.data.total || 0),
|
|
||||||
totalPages: Number(res.data.totalPages || 0),
|
|
||||||
currentPage: Number(res.data.currentPage || params.page),
|
|
||||||
size: Number(res.data.size || params.size),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
deleteUser(id: number): Promise<ApiResponse> {
|
|
||||||
return request.delete(`/api/admin/users/${id}`)
|
|
||||||
},
|
|
||||||
getOrders(params: { page: number; size: number; keyword?: string; status?: string | '' }): Promise<ApiResponse<{ orders: AdminOrderRow[]; total: number; totalPages: number; currentPage: number; size: number }>> {
|
|
||||||
const query = { page: params.page, size: params.size, keyword: params.keyword, status: params.status === '' ? undefined : params.status }
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/admin/orders', query).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
orders: Array.isArray(res.data.orders) ? res.data.orders.map((item) => normalizeAdminOrder(item)) : [],
|
|
||||||
total: Number(res.data.total || 0),
|
|
||||||
totalPages: Number(res.data.totalPages || 0),
|
|
||||||
currentPage: Number(res.data.currentPage || params.page),
|
|
||||||
size: Number(res.data.size || params.size),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
getProducts(params: { page: number; size: number; keyword?: string; category?: string; status?: number | '' }): Promise<ApiResponse<{ products: AdminProductRow[]; total: number; totalPages: number; currentPage: number; size: number }>> {
|
|
||||||
const query = { page: params.page, size: params.size, keyword: params.keyword, category: params.category, status: params.status === '' ? undefined : params.status }
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/admin/products', query).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
products: Array.isArray(res.data.products) ? res.data.products.map((item) => normalizeAdminProduct(item)) : [],
|
|
||||||
total: Number(res.data.total || 0),
|
|
||||||
totalPages: Number(res.data.totalPages || 0),
|
|
||||||
currentPage: Number(res.data.currentPage || params.page),
|
|
||||||
size: Number(res.data.size || params.size),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
getSystemStatus(): Promise<ApiResponse<MonitorSystemStatus>> { return request.get('/api/admin/monitor/system') },
|
|
||||||
getRedisStatus(): Promise<ApiResponse<RedisNodeStatus[]>> { return request.get('/api/admin/monitor/redis') },
|
|
||||||
getProduct(id: number): Promise<ApiResponse<AdminProductRow>> {
|
|
||||||
return request.get<ApiResponse<any>>(`/api/admin/products/${id}`).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) }))
|
|
||||||
},
|
|
||||||
createProduct(data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
|
|
||||||
return request.post<ApiResponse<any>>('/api/admin/products', data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) }))
|
|
||||||
},
|
|
||||||
updateProduct(id: number, data: Record<string, unknown>): Promise<ApiResponse<AdminProductRow>> {
|
|
||||||
return request.put<ApiResponse<any>>(`/api/admin/products/${id}`, data).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: res.data ? normalizeAdminProduct(res.data) : undefined,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
deleteProduct(id: number): Promise<ApiResponse> { return request.delete(`/api/admin/products/${id}`) },
|
|
||||||
getReviewStats(): Promise<ApiResponse<AdminReviewStats>> { return request.get('/api/admin/reviews/stats') },
|
|
||||||
getFavoriteStats(): Promise<ApiResponse<AdminFavoriteStats>> { return request.get('/api/admin/favorites/stats') },
|
|
||||||
getReviews(params: { page: number; size: number; keyword?: string }): Promise<ApiResponse<{ reviews: AdminReviewRow[]; total: number; totalPages: number; currentPage: number; size: number }>> {
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/admin/reviews', params).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
reviews: Array.isArray(res.data.reviews) ? (res.data.reviews as AdminReviewRow[]) : [],
|
|
||||||
total: Number(res.data.total || 0),
|
|
||||||
totalPages: Number(res.data.totalPages || 0),
|
|
||||||
currentPage: Number(res.data.currentPage || params.page),
|
|
||||||
size: Number(res.data.size || params.size),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
updateReview(id: number, data: { status?: number; adminReply?: string }): Promise<ApiResponse> { return request.put(`/api/admin/reviews/${id}`, data) },
|
|
||||||
deleteReview(id: number): Promise<ApiResponse> { return request.delete(`/api/admin/reviews/${id}`) },
|
|
||||||
getFavorites(params: { page: number; size: number; keyword?: string }): Promise<ApiResponse<{ favorites: AdminFavoriteRow[]; total: number; totalPages: number; currentPage: number; size: number }>> {
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/admin/favorites', params).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
favorites: Array.isArray(res.data.favorites) ? (res.data.favorites as AdminFavoriteRow[]) : [],
|
|
||||||
total: Number(res.data.total || 0),
|
|
||||||
totalPages: Number(res.data.totalPages || 0),
|
|
||||||
currentPage: Number(res.data.currentPage || params.page),
|
|
||||||
size: Number(res.data.size || params.size),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
deleteFavorite(id: number): Promise<ApiResponse> { return request.delete(`/api/admin/favorites/${id}`) },
|
|
||||||
migrateLegacyOrderItems(): Promise<ApiResponse<{ totalOrders: number; migrated: number; skipped: number }>> { return request.post('/api/admin/orders/migrate-items') },
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse, CartItem } from '@/types/api'
|
|
||||||
import { normalizeCartItems, normalizeOrder } from '@/utils/normalizers'
|
|
||||||
|
|
||||||
export const cartApi = {
|
|
||||||
// 获取购物车
|
|
||||||
getCart(): Promise<ApiResponse<CartItem[]>> {
|
|
||||||
return request.get<ApiResponse<any>>('/api/cart').then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeCartItems(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 添加到购物车
|
|
||||||
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/update', { productId: Number(itemId), quantity })
|
|
||||||
},
|
|
||||||
|
|
||||||
// 删除商品
|
|
||||||
removeItem(itemId: string): Promise<ApiResponse> {
|
|
||||||
return request.delete('/api/cart/remove', { productId: Number(itemId) })
|
|
||||||
},
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
batchRemove(ids: string[]): Promise<ApiResponse> {
|
|
||||||
return request.delete('/api/cart/batch-remove', { productIds: ids.map(Number) })
|
|
||||||
},
|
|
||||||
|
|
||||||
// 清空购物车
|
|
||||||
clearCart(): Promise<ApiResponse> {
|
|
||||||
return request.delete('/api/cart/clear')
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取购物车数量
|
|
||||||
getCount(): Promise<ApiResponse<{ count: number }>> {
|
|
||||||
return request.get('/api/cart/count')
|
|
||||||
},
|
|
||||||
|
|
||||||
checkout(ids?: string[]): Promise<ApiResponse<any>> {
|
|
||||||
return request.post<ApiResponse<any>>('/api/cart/checkout', {
|
|
||||||
productIds: ids?.map(Number),
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeOrder(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse } from '@/types/api'
|
|
||||||
|
|
||||||
export interface FavoriteItem {
|
|
||||||
id: number
|
|
||||||
userId: number
|
|
||||||
productId: number
|
|
||||||
productName: string
|
|
||||||
productImageUrl: string
|
|
||||||
productPrice: number
|
|
||||||
productCategory: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const favoriteApi = {
|
|
||||||
getList(): Promise<ApiResponse<FavoriteItem[]>> {
|
|
||||||
return request.get('/api/favorite')
|
|
||||||
},
|
|
||||||
|
|
||||||
getCount(): Promise<ApiResponse<{ count: number }>> {
|
|
||||||
return request.get('/api/favorite/count')
|
|
||||||
},
|
|
||||||
|
|
||||||
check(productId: number): Promise<ApiResponse<{ favorited: boolean }>> {
|
|
||||||
return request.get('/api/favorite/check', { productId })
|
|
||||||
},
|
|
||||||
|
|
||||||
toggle(productId: number): Promise<ApiResponse<{ favorited: boolean }>> {
|
|
||||||
return request.post('/api/favorite/toggle', { productId })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse, FlashSale, PageParams, PageResponse } from '@/types/api'
|
|
||||||
import { mapOrderStatus, normalizeFlashSale, normalizePage } from '@/utils/normalizers'
|
|
||||||
|
|
||||||
const flashSaleStatusToCode = (status?: string) => {
|
|
||||||
if (status === 'UPCOMING') return 1
|
|
||||||
if (status === 'ACTIVE') return 2
|
|
||||||
if (status === 'ENDED') return 3
|
|
||||||
if (status === 'PAUSED') return 4
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const flashSaleSortField = (sort?: string) => {
|
|
||||||
if (sort === 'flashPrice') return 'flashPrice'
|
|
||||||
if (sort === 'endTime') return 'endTime'
|
|
||||||
return 'startTime'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const flashsaleApi = {
|
|
||||||
// 获取限时活动统计信息(即将开始/正在进行/我的参与/抢购成功)
|
|
||||||
getStatistics(): Promise<ApiResponse<{ upcoming: number; active: number; participated: number; success: number }>> {
|
|
||||||
return request.get('/api/flashsale/statistics')
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取限时活动列表
|
|
||||||
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<FlashSale>>> {
|
|
||||||
return request.post<ApiResponse<Record<string, any>>>('/api/flashsale/list', {
|
|
||||||
status: flashSaleStatusToCode(params?.status),
|
|
||||||
page: params?.page ?? 0,
|
|
||||||
size: params?.size ?? 10,
|
|
||||||
sortBy: flashSaleSortField(params?.sort),
|
|
||||||
sortDirection: params?.order || 'asc',
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizePage(res.data, normalizeFlashSale),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取正在进行的限时活动
|
|
||||||
getActive(limit?: number): Promise<ApiResponse<FlashSale[]>> {
|
|
||||||
return request.get<ApiResponse<any[]>>('/api/flashsale/active').then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: (Array.isArray(res.data) ? res.data : [])
|
|
||||||
.map((item) => normalizeFlashSale(item))
|
|
||||||
.slice(0, limit ?? Number.MAX_SAFE_INTEGER),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取限时活动详情
|
|
||||||
getDetail(id: number): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.get<ApiResponse<any>>(`/api/flashsale/${id}`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 参与限时
|
|
||||||
participate(data: {
|
|
||||||
flashSaleId: number;
|
|
||||||
quantity: number;
|
|
||||||
timestamp?: number;
|
|
||||||
}): Promise<ApiResponse<{ orderId: number }>> {
|
|
||||||
return request.post<ApiResponse<any>>('/api/flashsale/participate', data).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
orderId: Number(res.data?.orderId || res.data?.id || 0),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取用户参与记录
|
|
||||||
getUserRecords(): Promise<ApiResponse<any[]>> {
|
|
||||||
return request.post<ApiResponse<Record<string, any>>>('/api/order/my-orders', {
|
|
||||||
orderType: 2,
|
|
||||||
page: 0,
|
|
||||||
size: 100,
|
|
||||||
sortBy: 'createdAt',
|
|
||||||
sortDirection: 'desc',
|
|
||||||
}).then((res) => {
|
|
||||||
const content = Array.isArray(res.data?.content) ? res.data.content : []
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: content.map((item: Record<string, any>) => ({
|
|
||||||
id: item.id,
|
|
||||||
success: mapOrderStatus(item.status) !== 'CANCELLED',
|
|
||||||
status: item.status,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// 检查用户是否可以参与
|
|
||||||
checkEligibility(flashSaleId: number): Promise<ApiResponse<{
|
|
||||||
eligible: boolean;
|
|
||||||
reason?: string;
|
|
||||||
remainingQuota?: number;
|
|
||||||
}>> {
|
|
||||||
return this.getDetail(flashSaleId).then((res) => {
|
|
||||||
const eligible = res.data.status === 'ACTIVE' && res.data.remainingStock > 0
|
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
success: true,
|
|
||||||
message: '检查成功',
|
|
||||||
data: {
|
|
||||||
eligible,
|
|
||||||
reason: eligible ? '' : '活动未开始、已结束或库存不足',
|
|
||||||
remainingQuota: res.data.limitPerUser,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
create(data: {
|
|
||||||
productId: number
|
|
||||||
flashPrice: number
|
|
||||||
flashStock: number
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
}): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.post<ApiResponse<any>>('/api/flashsale/create', data).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.put<ApiResponse<any>>(`/api/flashsale/${id}`, data).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
delete(id: number): Promise<ApiResponse> {
|
|
||||||
return request.delete(`/api/flashsale/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
publish(id: number): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/publish`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
pause(id: number): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/pause`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
resume(id: number): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/resume`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
end(id: number): Promise<ApiResponse<FlashSale>> {
|
|
||||||
return request.post<ApiResponse<any>>(`/api/flashsale/${id}/end`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeFlashSale(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse, GroupBuying, GroupBuyingGroup, GroupBuyingStatistics, PageParams, PageResponse } from '@/types/api'
|
|
||||||
import { normalizeGroupBuying, normalizeGroupBuyingGroup, normalizePage } from '@/utils/normalizers'
|
|
||||||
|
|
||||||
const groupBuyingStatusToCode = (status?: string) => {
|
|
||||||
if (status === 'DRAFT') return 0
|
|
||||||
if (status === 'UPCOMING') return 1
|
|
||||||
if (status === 'ACTIVE') return 2
|
|
||||||
if (status === 'ENDED') return 3
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const groupbuyingApi = {
|
|
||||||
getStatistics(): Promise<ApiResponse<GroupBuyingStatistics>> {
|
|
||||||
return request.get('/api/groupbuying/statistics')
|
|
||||||
},
|
|
||||||
|
|
||||||
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<GroupBuying>>> {
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/list', {
|
|
||||||
status: groupBuyingStatusToCode(params?.status),
|
|
||||||
page: params?.page ?? 0,
|
|
||||||
size: params?.size ?? 10,
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizePage(res.data, normalizeGroupBuying),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
getDetail(id: number): Promise<ApiResponse<GroupBuying>> {
|
|
||||||
return request.get<ApiResponse<any>>(`/api/groupbuying/${id}`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeGroupBuying(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
getGroups(id: number, params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>(`/api/groupbuying/${id}/groups`, {
|
|
||||||
page: params?.page ?? 0,
|
|
||||||
size: params?.size ?? 10,
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizePage(res.data, normalizeGroupBuyingGroup),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
joinGroup(data: { groupBuyingId: number; groupId?: number }): Promise<ApiResponse<{
|
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
groupId: number
|
|
||||||
groupNo: string
|
|
||||||
orderId: number
|
|
||||||
}>> {
|
|
||||||
return request.post('/api/groupbuying/join', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
getGroupDetail(groupId: number): Promise<ApiResponse<GroupBuyingGroup>> {
|
|
||||||
return request.get<ApiResponse<any>>(`/api/groupbuying/group/${groupId}`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeGroupBuyingGroup(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelMembership(groupId: number): Promise<ApiResponse> {
|
|
||||||
return request.post(`/api/groupbuying/group/${groupId}/cancel`)
|
|
||||||
},
|
|
||||||
|
|
||||||
getMyGroups(params?: PageParams): Promise<ApiResponse<PageResponse<GroupBuyingGroup>>> {
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/groupbuying/my-groups', {
|
|
||||||
page: params?.page ?? 0,
|
|
||||||
size: params?.size ?? 10,
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizePage(res.data, normalizeGroupBuyingGroup),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// Admin
|
|
||||||
create(data: {
|
|
||||||
productId: number
|
|
||||||
groupPrice: number
|
|
||||||
requiredMembers: number
|
|
||||||
durationMinutes: number
|
|
||||||
totalStock: number
|
|
||||||
maxPerUser: number
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
}): Promise<ApiResponse<GroupBuying>> {
|
|
||||||
return request.post<ApiResponse<any>>('/api/groupbuying/admin/create', data).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeGroupBuying(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
update(id: number, data: Record<string, unknown>): Promise<ApiResponse<GroupBuying>> {
|
|
||||||
return request.put<ApiResponse<any>>(`/api/groupbuying/admin/${id}`, data).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeGroupBuying(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
delete(id: number): Promise<ApiResponse> {
|
|
||||||
return request.delete(`/api/groupbuying/admin/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
preloadAll(): Promise<ApiResponse> {
|
|
||||||
return request.post('/api/groupbuying/admin/preload-all')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
|
|
||||||
export interface NotificationItem {
|
|
||||||
id: number
|
|
||||||
userId: number
|
|
||||||
type: 'flashsale' | 'order' | 'system'
|
|
||||||
title: string
|
|
||||||
message: string
|
|
||||||
link?: string
|
|
||||||
read: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiRes<T = any> {
|
|
||||||
success: boolean
|
|
||||||
message?: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notificationApi = {
|
|
||||||
/** 获取通知列表 */
|
|
||||||
getList(type?: string): Promise<ApiRes<NotificationItem[]>> {
|
|
||||||
return request.get('/api/notification/list', type ? { type } : undefined)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 获取未读数量 */
|
|
||||||
getUnreadCount(): Promise<ApiRes<number>> {
|
|
||||||
return request.get('/api/notification/unread-count')
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 标记单条已读 */
|
|
||||||
markAsRead(id: number): Promise<ApiRes> {
|
|
||||||
return request.put(`/api/notification/${id}/read`)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 全部标记已读 */
|
|
||||||
markAllAsRead(): Promise<ApiRes> {
|
|
||||||
return request.put('/api/notification/read-all')
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 清空所有通知 */
|
|
||||||
clearAll(): Promise<ApiRes> {
|
|
||||||
return request.delete('/api/notification/clear')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse, Order, PageParams, PageResponse } from '@/types/api'
|
|
||||||
import { normalizeOrder } from '@/utils/normalizers'
|
|
||||||
|
|
||||||
const orderStatusToCode = (status?: string) => {
|
|
||||||
if (status === 'PENDING') return 1
|
|
||||||
if (status === 'PAID') return 2
|
|
||||||
if (status === 'SHIPPED') return 3
|
|
||||||
if (status === 'COMPLETED') return 4
|
|
||||||
if (status === 'CANCELLED') return 5
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregateOrders = (rawOrders: Array<Record<string, any>>): Order[] => {
|
|
||||||
const groups = new Map<string, Array<Record<string, any>>>()
|
|
||||||
|
|
||||||
rawOrders.forEach((item) => {
|
|
||||||
const key = item.groupNo || item.orderNo || String(item.id)
|
|
||||||
if (!groups.has(key)) {
|
|
||||||
groups.set(key, [])
|
|
||||||
}
|
|
||||||
groups.get(key)!.push(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(groups.values()).map((group) => {
|
|
||||||
const [anchor] = group
|
|
||||||
const normalizedAnchor = normalizeOrder(anchor)
|
|
||||||
|
|
||||||
const items = group.flatMap((item) => normalizeOrder(item).items)
|
|
||||||
|
|
||||||
const totalAmount = items.reduce((sum, item) => sum + item.subtotal, 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...normalizedAnchor,
|
|
||||||
orderNo: anchor.groupNo || normalizedAnchor.orderNo,
|
|
||||||
totalAmount,
|
|
||||||
paymentAmount: totalAmount,
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const orderApi = {
|
|
||||||
create(data: {
|
|
||||||
items: Array<{ productId: number; quantity: number }>
|
|
||||||
addressId?: number
|
|
||||||
remark?: string
|
|
||||||
}): Promise<ApiResponse<Order>> {
|
|
||||||
const [firstItem] = data.items
|
|
||||||
return request.post<ApiResponse<any>>('/api/order/create', {
|
|
||||||
productId: firstItem.productId,
|
|
||||||
quantity: firstItem.quantity,
|
|
||||||
remark: data.remark,
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeOrder(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
getList(params?: PageParams & { status?: string }): Promise<ApiResponse<PageResponse<Order>>> {
|
|
||||||
return request.post<ApiResponse<Record<string, any>>>('/api/order/my-orders', {
|
|
||||||
status: orderStatusToCode(params?.status),
|
|
||||||
page: params?.page ?? 0,
|
|
||||||
size: params?.size ?? 10,
|
|
||||||
sortBy: params?.sort || 'createdAt',
|
|
||||||
sortDirection: params?.order || 'desc',
|
|
||||||
}).then((res) => {
|
|
||||||
const rawContent = Array.isArray(res.data.content) ? res.data.content : []
|
|
||||||
const content = aggregateOrders(rawContent)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
content,
|
|
||||||
totalElements: Number(res.data.totalElements || content.length),
|
|
||||||
totalPages: Number(res.data.totalPages || 1),
|
|
||||||
size: res.data.size || content.length,
|
|
||||||
number: res.data.currentPage || 0,
|
|
||||||
first: true,
|
|
||||||
last: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
getDetail(id: number): Promise<ApiResponse<Order>> {
|
|
||||||
return request.get<ApiResponse<any>>(`/api/order/${id}`).then(async (res) => {
|
|
||||||
if (res.data.groupNo) {
|
|
||||||
const groupRes = await request.get<ApiResponse<any[]>>(`/api/order/group/${res.data.groupNo}`)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: aggregateOrders(groupRes.data)[0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: normalizeOrder(res.data),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
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 })
|
|
||||||
},
|
|
||||||
|
|
||||||
ship(id: number): Promise<ApiResponse> {
|
|
||||||
return request.post(`/api/order/${id}/ship`)
|
|
||||||
},
|
|
||||||
|
|
||||||
updateStatus(id: number, status: number, remark?: string): Promise<ApiResponse> {
|
|
||||||
return request.put('/api/order/status', { orderId: id, status, remark })
|
|
||||||
},
|
|
||||||
|
|
||||||
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<ApiResponse<any>>('/api/order/statistics').then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: {
|
|
||||||
total: Number(res.data.totalOrders || 0),
|
|
||||||
pending: Number(res.data.pendingPaymentOrders || 0),
|
|
||||||
paid: Number(res.data.paidOrders || 0),
|
|
||||||
shipped: Number(res.data.shippedOrders || 0),
|
|
||||||
completed: Number(res.data.completedOrders || 0),
|
|
||||||
cancelled: Number(res.data.cancelledOrders || 0),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { request } from '../request'
|
|
||||||
import type { ApiResponse, Product, PageParams, PageResponse } from '@/types/api'
|
|
||||||
import { normalizePage, normalizeProduct } from '@/utils/normalizers'
|
|
||||||
|
|
||||||
export const productApi = {
|
|
||||||
// 获取商品列表
|
|
||||||
getList(params?: PageParams & {
|
|
||||||
keyword?: string;
|
|
||||||
category?: string;
|
|
||||||
minPrice?: number;
|
|
||||||
maxPrice?: number;
|
|
||||||
}): Promise<ApiResponse<PageResponse<Product>>> {
|
|
||||||
return request.get<ApiResponse<Record<string, any>>>('/api/product/list', {
|
|
||||||
page: params?.page ?? 0,
|
|
||||||
size: params?.size ?? 10,
|
|
||||||
keyword: params?.keyword,
|
|
||||||
category: params?.category,
|
|
||||||
minPrice: params?.minPrice,
|
|
||||||
maxPrice: params?.maxPrice,
|
|
||||||
sortBy: params?.sort || 'id',
|
|
||||||
sortDirection: params?.order || 'desc',
|
|
||||||
status: 1,
|
|
||||||
}).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizePage(res.data, normalizeProduct),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取热门商品
|
|
||||||
getHot(limit = 8): Promise<ApiResponse<Product[]>> {
|
|
||||||
return request.get<ApiResponse<any[]>>('/api/product/hot', { limit }).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: Array.isArray(res.data) ? res.data.map((item) => normalizeProduct(item)) : [],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取商品详情
|
|
||||||
getDetail(id: number): Promise<ApiResponse<Product>> {
|
|
||||||
return request.get<ApiResponse<any>>(`/api/product/${id}`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: normalizeProduct(res.data),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 搜索商品
|
|
||||||
search(keyword: string): Promise<ApiResponse<Product[]>> {
|
|
||||||
return this.getList({ keyword, page: 0, size: 50 }).then((res) => ({
|
|
||||||
...res,
|
|
||||||
data: res.data.content,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取商品分类
|
|
||||||
getCategories(): Promise<ApiResponse<string[]>> {
|
|
||||||
return request.get('/api/product/categories')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user