first commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: dashboard-enrich
|
||||||
|
overview: 前后端联动丰富运营仪表盘:后端新增趋势统计接口并扩展 summary 的「内容审核 / VIP / 商品」维度,前端引入 ECharts 用折线图、环形图等呈现。
|
||||||
|
design:
|
||||||
|
architecture:
|
||||||
|
framework: vue
|
||||||
|
styleKeywords:
|
||||||
|
- 现代清爽
|
||||||
|
- 渐变卡片
|
||||||
|
- 数据可视化
|
||||||
|
- 环形图
|
||||||
|
fontSystem:
|
||||||
|
fontFamily: PingFang SC
|
||||||
|
heading:
|
||||||
|
size: 20px
|
||||||
|
weight: 700
|
||||||
|
subheading:
|
||||||
|
size: 15px
|
||||||
|
weight: 600
|
||||||
|
body:
|
||||||
|
size: 13px
|
||||||
|
weight: 400
|
||||||
|
colorSystem:
|
||||||
|
primary:
|
||||||
|
- "#4F6EF7"
|
||||||
|
- "#6A5CF0"
|
||||||
|
- "#8B5CF6"
|
||||||
|
background:
|
||||||
|
- "#F5F7FB"
|
||||||
|
- "#FFFFFF"
|
||||||
|
text:
|
||||||
|
- "#1F2437"
|
||||||
|
- "#5D6685"
|
||||||
|
- "#9AA3C0"
|
||||||
|
functional:
|
||||||
|
- "#22C55E"
|
||||||
|
- "#F59E0B"
|
||||||
|
- "#EF4444"
|
||||||
|
- "#06B6D4"
|
||||||
|
todos:
|
||||||
|
- id: extend-dashboard-dto
|
||||||
|
content: 扩展后端仪表盘 DTO(Content/VIP/Goods/Trend)
|
||||||
|
status: completed
|
||||||
|
- id: extend-stats-dao
|
||||||
|
content: StatsDao 新增内容/VIP/商品/趋势统计方法
|
||||||
|
status: completed
|
||||||
|
- id: extend-dashboard-service
|
||||||
|
content: DashboardService 聚合新维度并实现 Trend 方法
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- extend-dashboard-dto
|
||||||
|
- extend-stats-dao
|
||||||
|
- id: add-trend-route
|
||||||
|
content: 新增 Trend handler 并注册 dashboard/trends 路由
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- extend-dashboard-service
|
||||||
|
- id: setup-echarts
|
||||||
|
content: 安装 echarts 并封装 BaseChart 通用组件
|
||||||
|
status: completed
|
||||||
|
- id: extend-dashboard-api
|
||||||
|
content: 扩展前端 dashboard 类型定义与 API 函数
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- extend-dashboard-dto
|
||||||
|
- id: rebuild-dashboard-view
|
||||||
|
content: 重构仪表盘页渲染趋势折线图与三个分布环形图
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- setup-echarts
|
||||||
|
- extend-dashboard-api
|
||||||
|
---
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
在现有运营仪表盘基础上进行可视化升级:保留营收、用户、订单概况与待办横幅,新增时间趋势折线图和内容审核、VIP 会员、商品分类三个分布面板,让运营人员一眼掌握核心数据动态。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 近 7/30 天可切换的趋势折线图:新增用户数、已支付订单量、营收金额(双坐标轴)
|
||||||
|
- 内容审核概况:穿搭审核状态(待审核/通过/不通过/封禁)、衣物图片处理状态(处理中/成功/失败)、衣柜分享状态(正常/取消/过期/封禁)分布
|
||||||
|
- VIP 会员概况:有效会员类型分布(月度/年度/体验)及 7 天内即将到期会员数
|
||||||
|
- 商品分类统计:各类型商品数量分布与上架/下架占比
|
||||||
|
- 保留现有 summary 各指标与页面布局,新增内容向后兼容、不影响已上线展示
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 后端:Go + Gin + GORM(复用现有 StatsDao 直接聚合 MySQL 业务表,不改表结构)
|
||||||
|
- 前端:Vue 3 + TypeScript + Element Plus + ECharts(新增 echarts 依赖)
|
||||||
|
- 图表:ECharts 折线图 + 环形饼图
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
后端复用现有 `DashboardService` 与 `StatsDao` 模式,将 `GET /dashboard/summary` 的响应扩展出 `content`、`vip`、`goods` 三个子结构(一次请求拿到全部概况),另新增 `GET /dashboard/trends?days=7|30` 返回按天对齐的趋势序列(无数据日期补零)。前端引入 ECharts,封装通用 `BaseChart.vue` 管理实例生命周期,在 DashboardView 内组合趋势折线图与三个环形分布图。
|
||||||
|
|
||||||
|
关键决策:
|
||||||
|
|
||||||
|
- **趋势单独一个接口**:summary 保持轻量只做概况,趋势数据按需加载并支持 7/30 天切换,避免每次都聚合全量日期序列。
|
||||||
|
- **按天补零在 Go 端完成**:SQL 只 group by 有数据的日期,Go 端从起始日期循环补零,保证折线图 x 轴连续、时区正确。
|
||||||
|
- **双 y 轴**:营收(元)与数量量纲不同,趋势图用左轴数量、右轴金额。
|
||||||
|
- **向后兼容**:DashboardResponse 只增字段不改字段,前端旧字段继续渲染,新字段独立面板。
|
||||||
|
|
||||||
|
数据口径:
|
||||||
|
|
||||||
|
- 趋势新增用户:`users.created_at` 按日计数;订单量:`orders.order_status IN (2,5)` 且按 `pay_time` 计数(与营收同口径);营收:`orders.pay_amount` 求和(分)。
|
||||||
|
- VIP 有效会员:`vip_type IN (2,3,4) AND vip_expire > now`;即将到期:`vip_expire BETWEEN now AND now+7*86400`。
|
||||||
|
- 内容审核:穿搭按 `audit_status` 与 `status=2` 封禁数;衣物按 `image_process_status` 分组(1/2/3 归为处理中、4 成功、5 失败);分享按 `status` 分组。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Frontend
|
||||||
|
DV[DashboardView.vue] --> BC[BaseChart.vue]
|
||||||
|
DV --> API[api/dashboard.ts]
|
||||||
|
DV --> TYPES[types/dashboard.ts]
|
||||||
|
end
|
||||||
|
subgraph Backend
|
||||||
|
H[SummaryHandler / TrendHandler] --> S[DashboardService]
|
||||||
|
S --> DAO[StatsDao]
|
||||||
|
DAO --> M[(MySQL 业务表)]
|
||||||
|
end
|
||||||
|
API -->|GET /dashboard/summary| H
|
||||||
|
API -->|GET /dashboard/trends| H
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键数据结构(后端 dto/dashboard.go 扩展)
|
||||||
|
|
||||||
|
```
|
||||||
|
type DashboardResponse struct {
|
||||||
|
Users DashboardUsers `json:"users"`
|
||||||
|
Orders DashboardOrders `json:"orders"`
|
||||||
|
Revenue DashboardRevenue `json:"revenue"`
|
||||||
|
Todo DashboardTodo `json:"todo"`
|
||||||
|
Content DashboardContent `json:"content"`
|
||||||
|
VIP DashboardVIP `json:"vip"`
|
||||||
|
Goods DashboardGoods `json:"goods"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardContent struct {
|
||||||
|
Outfit DashboardOutfitStats `json:"outfit"`
|
||||||
|
Clothes DashboardClothesStats `json:"clothes"`
|
||||||
|
Share DashboardShareStats `json:"share"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardVIP struct {
|
||||||
|
MemberTotal int64 `json:"member_total"`
|
||||||
|
Monthly int64 `json:"monthly"`
|
||||||
|
Yearly int64 `json:"yearly"`
|
||||||
|
Trial int64 `json:"trial"`
|
||||||
|
ExpiringSoon int64 `json:"expiring_soon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardGoods struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
OnShelf int64 `json:"on_shelf"`
|
||||||
|
OffShelf int64 `json:"off_shelf"`
|
||||||
|
ByType []GoodsTypeCount `json:"by_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendPoint struct {
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
NewUsers int64 `json:"new_users"`
|
||||||
|
Orders int64 `json:"orders"`
|
||||||
|
Revenue int64 `json:"revenue"` // 分
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
后端 /root/zt-closet-manage-backend/
|
||||||
|
├── internal/dto/dashboard.go # [MODIFY] 扩展 Content/VIP/Goods 结构与 Trend 请求/响应结构
|
||||||
|
├── internal/mysqldao/stats.go # [MODIFY] 新增 ContentStatsGet/VIPStatsGet/GoodsStatsGet/TrendStatsGet
|
||||||
|
├── internal/service/dashboard.go # [MODIFY] Summary 聚合新维度,新增 Trend 方法
|
||||||
|
├── internal/routers/api/v1/dashboard.go # [MODIFY] 新增 Trend handler
|
||||||
|
└── internal/routers/router.go # [MODIFY] 注册 GET /dashboard/trends
|
||||||
|
|
||||||
|
前端 /root/zt-closet-manage-frontend/
|
||||||
|
├── package.json # [MODIFY] 新增 echarts 依赖
|
||||||
|
├── src/components/BaseChart.vue # [NEW] ECharts 通用封装(init/resize/dispose/option 更新)
|
||||||
|
├── src/types/dashboard.ts # [MODIFY] 新增 Content/VIP/Goods/Trend 类型
|
||||||
|
├── src/api/dashboard.ts # [MODIFY] 新增 getDashboardTrends
|
||||||
|
└── src/views/dashboard/DashboardView.vue # [MODIFY] 趋势折线图 + 内容/VIP/商品三个环形图面板
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- DAO 趋势 SQL 使用 `DATE(FROM_UNIXTIME(created_at))` / `DATE(FROM_UNIXTIME(pay_time))` 分组,参数为秒级时间戳;返回 map[date]value 后由 service 按起始日期补零。
|
||||||
|
- StatsDao 各新方法保持 `func (d *StatsDao) Xxx(ctx context.Context, ...) (*XxxStats, error)` 风格,`WithContext` + `span.RecordError`,与现有 stats.go 一致。
|
||||||
|
- `TrendStatsGet` 用一次循环构造 `[]TrendPoint`,避免 N+1;`days` 参数在 handler 用 `ShouldBindQuery` 校验并限制 1~90。
|
||||||
|
- 前端 `BaseChart.vue` 使用 `echarts.init`,`watch` option 深度更新,`ResizeObserver` 监听容器尺寸,`onBeforeUnmount` 时 `dispose` 防泄漏。
|
||||||
|
- 趋势图折线:新增用户/订单量挂左轴,营收挂右轴(`formatMoney` 转元显示);分布图用 pie 环形,复用现有 `enumLabel` 常量映射文案。
|
||||||
|
- 不改动已有接口字段,保证 summary 旧展示路径不回归;仅在前端 `DashboardSummary` 类型上新增可选字段并渲染新面板。
|
||||||
|
|
||||||
|
## 设计风格
|
||||||
|
|
||||||
|
延续现有管理后台的现代清爽风格,在现有渐变 hero 横幅、白底圆角卡片基础上做可视化升级,强调信息密度与层次感。趋势图采用通栏大卡片,内容审核、VIP 会员、商品分类采用三列卡片布局,图表配色与页面既有渐变色系统一。
|
||||||
|
|
||||||
|
## 页面布局
|
||||||
|
|
||||||
|
- 顶部保留渐变欢迎横幅与待办数字
|
||||||
|
- 营收四卡片行保持不变
|
||||||
|
- 新增通栏「数据趋势」卡片:7 天 / 30 天分段切换 + 双轴折线图
|
||||||
|
- 新增三列分布卡片:内容审核概况、VIP 会员概况、商品分类统计,各配环形图与关键数字
|
||||||
|
- 保留原有用户概况、订单概况面板
|
||||||
|
|
||||||
|
## 交互
|
||||||
|
|
||||||
|
- 趋势图切换 7/30 天时局部刷新,不整页加载
|
||||||
|
- 环形图悬停显示数量与占比 tooltip
|
||||||
|
- 卡片沿用 hover 上浮阴影,保持与现有 stat-card 一致的微动效
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: dashboard-usage-interaction
|
||||||
|
overview: 在运营仪表盘新增「今日消耗、COS 图数、点赞/收藏」指标:后端扩展 summary 聚合接口,前端新增展示卡片。
|
||||||
|
todos:
|
||||||
|
- id: add-social-models
|
||||||
|
content: 在 business.go 中新增 Like、Collect 表模型镜像
|
||||||
|
status: completed
|
||||||
|
- id: extend-dto-dao
|
||||||
|
content: 扩展 dashboard DTO 并新增 UsageStatsGet/SocialStatsGet 聚合方法
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- add-social-models
|
||||||
|
- id: aggregate-summary
|
||||||
|
content: 在 DashboardService.Summary 中聚合 usage 与 social 维度
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- extend-dto-dao
|
||||||
|
- id: extend-frontend-types
|
||||||
|
content: 扩展前端 dashboard 类型定义,新增 usage/social 字段
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- extend-dto-dao
|
||||||
|
- id: render-dashboard-metrics
|
||||||
|
content: 在仪表盘页新增「今日消耗」「内容互动」两列指标卡片
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- extend-frontend-types
|
||||||
|
---
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
在现有运营仪表盘基础上,新增「今日消耗」与「内容互动」两组共 6 个运营指标,让运营人员直观掌握每日额度消耗与社区内容互动数据。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 今日 AI 推荐消耗次数
|
||||||
|
- 今日衣柜分享消耗次数
|
||||||
|
- 今日社区分享消耗次数
|
||||||
|
- COS 总图数
|
||||||
|
- 点赞数
|
||||||
|
- 收藏数
|
||||||
|
|
||||||
|
## 展示说明
|
||||||
|
|
||||||
|
- 在营收卡片下方、数据趋势面板上方,新增一行两列卡片:「今日消耗」展示 AI 推荐/衣柜分享/社区分享三项今日次数,「内容互动」展示 COS 图数/点赞数/收藏数三项累计值。
|
||||||
|
- 沿用现有 stat-card / mini-stat 卡片风格与图标配色,保持页面整体视觉一致。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 后端:Go + Gin + GORM,复用现有 `StatsDao` / `DashboardService`,不改表结构。
|
||||||
|
- 前端:Vue 3 + TypeScript + Element Plus,复用现有卡片样式,无需新增依赖。
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
扩展现有 `GET /dashboard/summary` 响应的 `DashboardResponse`,新增 `usage`(今日消耗)与 `social`(互动+COS)两个子结构,一次请求返回全部新增指标;前端在 `DashboardView.vue` 中新增一行两列卡片渲染。
|
||||||
|
|
||||||
|
数据口径(已通过业务服务源码确认):
|
||||||
|
|
||||||
|
| 指标 | 数据来源 | 统计口径 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 今日 AI 推荐消耗次数 | `user_daily_usages` | `stat_date = 今日(YYYYMMDD)`,`SUM(ai_recommend_count)` |
|
||||||
|
| 今日衣柜分享消耗次数 | `user_daily_usages` | `stat_date = 今日(YYYYMMDD)`,`SUM(closet_share_count)` |
|
||||||
|
| 今日社区分享消耗次数 | `user_daily_usages` | `stat_date = 今日(YYYYMMDD)`,`SUM(outfit_share_count)` |
|
||||||
|
| COS 总图数 | `clothes` | `processed_image != ''` 的记录数 |
|
||||||
|
| 点赞数 | `likes` | `status = 1`(已点赞)计数 |
|
||||||
|
| 收藏数 | `collects` | `status = 1`(已收藏)计数 |
|
||||||
|
|
||||||
|
|
||||||
|
关键决策:
|
||||||
|
|
||||||
|
- **今日消耗合并为一次查询**:`user_daily_usages` 一行同时含三项计数字段,直接 `SUM` 三列并 `WHERE stat_date = ?`,避免三次查询。
|
||||||
|
- **COS 总图数口径**:`clothes.processed_image` 是上传 COS 成功后回填的对象键路径,非空即代表已存图。
|
||||||
|
- **点赞/收藏按累计总数**:用户未加「今日」前缀,与「今日消耗」形成区分,取 `status=1` 全量计数。
|
||||||
|
- **Like/Collect 模型镜像补齐**:管理后台 `business.go` 当前缺少这两个表模型,按业务服务字段镜像新增,软删除由 `Model.DeletedAt` 自动过滤。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
沿用现有分层:`DashboardHandler.Summary → DashboardService.Summary → StatsDao → MySQL`。本次仅扩展 summary 聚合链路,不新增路由与 handler。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
DV[DashboardView.vue] --> API[api/dashboard.ts]
|
||||||
|
API --> H[SummaryHandler]
|
||||||
|
H --> S[DashboardService.Summary]
|
||||||
|
S --> DAO[StatsDao]
|
||||||
|
DAO --> M[(MySQL 业务表)]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
后端 /root/zt-closet-manage-backend/
|
||||||
|
├── internal/mysqlmodel/business.go # [MODIFY] 新增 Like、Collect 表模型镜像
|
||||||
|
├── internal/dto/dashboard.go # [MODIFY] 新增 DashboardUsage/DashboardSocial 及 DashboardResponse 字段
|
||||||
|
├── internal/mysqldao/stats.go # [MODIFY] 新增 UsageStatsGet/SocialStatsGet
|
||||||
|
└── internal/service/dashboard.go # [MODIFY] Summary 聚合 usage/social 维度
|
||||||
|
|
||||||
|
前端 /root/zt-closet-manage-frontend/
|
||||||
|
├── src/types/dashboard.ts # [MODIFY] 新增 DashboardUsage/DashboardSocial 类型及 DashboardSummary 字段
|
||||||
|
└── src/views/dashboard/DashboardView.vue # [MODIFY] 新增「今日消耗」「内容互动」两列卡片
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
// dto/dashboard.go
|
||||||
|
type DashboardUsage struct {
|
||||||
|
AIRecommend int64 `json:"ai_recommend"` // 今日AI推荐消耗次数
|
||||||
|
ClosetShare int64 `json:"closet_share"` // 今日衣柜分享消耗次数
|
||||||
|
OutfitShare int64 `json:"outfit_share"` // 今日社区分享消耗次数
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardSocial struct {
|
||||||
|
CosImageCount int64 `json:"cos_image_count"` // COS总图数
|
||||||
|
LikeCount int64 `json:"like_count"` // 点赞数
|
||||||
|
CollectCount int64 `json:"collect_count"` // 收藏数
|
||||||
|
}
|
||||||
|
|
||||||
|
// mysqldao/stats.go
|
||||||
|
func (d *StatsDao) UsageStatsGet(ctx context.Context, statDate int64) (*UsageStats, error)
|
||||||
|
func (d *StatsDao) SocialStatsGet(ctx context.Context) (*SocialStats, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- `UsageStatsGet` 用单条 `Select("COALESCE(SUM(ai_recommend_count),0), COALESCE(SUM(closet_share_count),0), COALESCE(SUM(outfit_share_count),0)")` 配合 `Where("stat_date = ?", statDate)`,`Scan` 到三个字段。
|
||||||
|
- `SocialStatsGet` 三条独立计数:`clothes.processed_image <> ''`、`likes.status = 1`、`collects.status = 1`,各自带 `span.RecordError` 与现有 DAO 风格一致。
|
||||||
|
- `Summary` 内使用已存在的 `todayStatDate()`(service/service.go)计算今日 YYYYMMDD 整数,传给 `UsageStatsGet`。
|
||||||
|
- 前端新增字段为可选读取(`summary?.usage` / `summary?.social`),旧后端响应未升级时不会报错;采用 6 个 `mini-stat` 数字块,分组放入两个 `panel`。
|
||||||
|
- 不改动已有接口字段与展示路径,保证向后兼容。
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: 前后端逻辑对齐
|
||||||
|
overview: 以后端最新代码为准,修复前端与后端不一致的地方,重点解决用户额度调整语义错配、额度凭证(grants)功能缺失、商品与订单字段缺失等问题。
|
||||||
|
todos:
|
||||||
|
- id: align-user-types-api
|
||||||
|
content: 对齐用户额度/凭证类型并新增 revokeGrant 回收 API
|
||||||
|
status: completed
|
||||||
|
- id: update-user-view
|
||||||
|
content: 更新用户页额度发放表单与详情凭证列表回收
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- align-user-types-api
|
||||||
|
- id: align-goods-types-view
|
||||||
|
content: 补齐商品 valid_days 等字段并更新商品表单与展示
|
||||||
|
status: completed
|
||||||
|
- id: align-order-types-view
|
||||||
|
content: 补齐订单虚拟支付字段、枚举并更新订单详情展示
|
||||||
|
status: completed
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户需求
|
||||||
|
|
||||||
|
后端管理代码有更新,需全量核对后端(/root/zt-closet-manage-backend)与前端(/root/zt-closet-manage-frontend)的逻辑一致性。以后端最新代码为基准,前端做适配,确保接口字段、请求语义、枚举映射完全对齐。
|
||||||
|
|
||||||
|
## 核对结论与变更点
|
||||||
|
|
||||||
|
经全量对比,登录鉴权、仪表盘、内容审核、通知、退款、响应结构与错误码均已一致,无需改动。存在以下 5 处不一致需要修复:
|
||||||
|
|
||||||
|
1. **用户额度调整语义错配(功能不可用)**:后端已改为「发放额度凭证」语义,请求体为 `quota_type + amount + expire_days`;前端仍使用旧的 `quota_type + new_total + reason`,导致提交必失败。
|
||||||
|
2. **用户额度凭证(grants)功能缺失**:后端用户详情已返回 `grants` 列表,并提供 `DELETE /users/:uuid/grants/:id` 回收接口;前端类型、API、页面展示与回收操作均缺失。
|
||||||
|
3. **商品字段缺失**:后端商品新增 `valid_days`(有效期天数,可编辑)及只读的 `product_id`、`product_status`(虚拟支付道具);前端类型、表单与列表展示均未覆盖。
|
||||||
|
4. **订单字段缺失**:后端订单新增 `xpay_product_id`、`xpay_platform`、`xpay_env`(虚拟支付信息);前端类型与详情展示均未覆盖。
|
||||||
|
5. **支付渠道枚举缺失**:后端支付渠道为「1=微信支付、2=微信虚拟支付」;前端 `PAY_CHANNEL` 仅定义 value=1,虚拟支付订单会显示为 `-`。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 前端:Vue 3 + TypeScript + Element Plus + Pinia + axios(沿用现有架构,不引入新依赖)
|
||||||
|
- 后端:Go + Gin(只读参考,不修改)
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
以后端 `internal/dto/*.go` 的 JSON tag 与路由为唯一基准,逐项对齐前端 `src/types`、`src/api`、`src/utils/constants` 与对应 `src/views` 页面。
|
||||||
|
|
||||||
|
### 关键决策
|
||||||
|
|
||||||
|
- **额度调整语义重构**:将前端 `UserQuotaAdjustRequest` 改为 `{ quota_type, amount, expire_days }`,移除 `new_total`、`reason`。页面表单同步改为「发放数量 + 有效期天数」,提交 `amount`(1~100000000)与 `expire_days`(1~3650)。这是当前最严重的不一致,修复后功能才可用。
|
||||||
|
- **补齐凭证管理闭环**:`UserDetailResponse` 增加 `grants: UserGrantItem[]`;`api/user.ts` 新增 `revokeGrant` 调用 `DELETE /users/:uuid/grants/:id`;详情抽屉展示凭证列表(类型/来源/数量/已用/到期/关联 ID)并增加「回收」操作,回收成功后重新拉取详情刷新。
|
||||||
|
- **只读字段与可编辑字段分离**:商品 `product_id`、`product_status` 为只读,仅展示、不参与创建/更新提交;`valid_days` 可编辑,加入表单回显与提交。商品更新接口为「传什么改什么」的指针语义,前端编辑时仅提交可编辑字段。
|
||||||
|
- **枚举与展示对齐**:`constants.ts` 新增 `GRANT_SOURCE`(1=会员包、2=加量包、3=广告激励),`PAY_CHANNEL` 补充 value=2(微信虚拟支付)。
|
||||||
|
- **单位约定不变**:价格仍为「分」,前端继续用 `yuanToCents`/`formatMoney` 换算,避免引入新的金额逻辑。
|
||||||
|
|
||||||
|
### 实现注意事项
|
||||||
|
|
||||||
|
- 所有字段命名严格遵循后端 JSON tag(snake_case),禁止自创字段名。
|
||||||
|
- 复用现有 `httpGet/httpPost/httpPut/httpDelete` 与 `enumLabel/enumTag/enumOptions/formatDate/formatText` 工具,不新增请求层逻辑。
|
||||||
|
- 回收凭证后需重新调用 `getUserDetail` 刷新详情,避免展示陈旧数据。
|
||||||
|
- 保持现有页面结构与组件风格,仅做字段级调整,不做无关重构。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
zt-closet-manage-frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── types/
|
||||||
|
│ │ ├── user.ts # [MODIFY] UserQuotaAdjustRequest 改为 amount+expire_days;新增 UserGrantItem;UserDetailResponse 增加 grants
|
||||||
|
│ │ ├── goods.ts # [MODIFY] GoodsItem 增加 valid_days/product_id/product_status;GoodsCreateRequest、GoodsUpdateRequest 增加 valid_days
|
||||||
|
│ │ └── order.ts # [MODIFY] OrderItem 增加 xpay_product_id/xpay_platform/xpay_env
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── user.ts # [MODIFY] 新增 revokeGrant(uuid, grantId) 调用 DELETE /users/:uuid/grants/:id
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ └── constants.ts # [MODIFY] 新增 GRANT_SOURCE 枚举;PAY_CHANNEL 补充 value=2
|
||||||
|
│ └── views/
|
||||||
|
│ ├── user/
|
||||||
|
│ │ └── UserListView.vue # [MODIFY] 额度表单改为发放数量+有效期天数;详情抽屉展示 grants 并增加回收操作
|
||||||
|
│ ├── goods/
|
||||||
|
│ │ └── GoodsListView.vue # [MODIFY] 表单增加 valid_days;列表/详情展示 product_id、product_status
|
||||||
|
│ └── order/
|
||||||
|
│ └── OrderListView.vue # [MODIFY] 订单详情抽屉展示虚拟支付字段
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键代码结构
|
||||||
|
|
||||||
|
用户域核心类型(需与后端 dto/user.go 严格对齐):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 额度发放请求(后端 UserQuotaAdjustRequest)
|
||||||
|
export interface UserQuotaAdjustRequest {
|
||||||
|
quota_type: number // 1=上传 2=AI 3=社区分享 4=衣柜分享
|
||||||
|
amount: number // 发放数量,min=1
|
||||||
|
expire_days: number // 有效期天数,min=1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户额度凭证(后端 UserGrantItem)
|
||||||
|
export interface UserGrantItem {
|
||||||
|
id: number
|
||||||
|
quota_type: number
|
||||||
|
amount: number
|
||||||
|
used: number
|
||||||
|
source: number // 1=会员包 2=加量包 3=广告激励
|
||||||
|
expire_time: number // 秒,0=跟随 VIP
|
||||||
|
ref_id: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户详情(后端 UserDetailResponse)
|
||||||
|
export interface UserDetailResponse extends UserListItem {
|
||||||
|
quotas: QuotaItem[]
|
||||||
|
grants: UserGrantItem[]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
name: 智能衣橱管理后台前端
|
||||||
|
overview: 基于 Vue3 + TypeScript + Vite 从零搭建「智能衣橱」管理后台前端,对接 Go 后端 /api/v1 全部接口,覆盖登录鉴权、仪表盘、用户/商品/订单退款/内容审核/通知/管理员管理 8 大模块。
|
||||||
|
design:
|
||||||
|
architecture:
|
||||||
|
framework: react
|
||||||
|
component: mui
|
||||||
|
---
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
基于 zt-closet-manage-backend 后端 API 契约,从零构建「智能衣橱」管理后台前端系统(Vue 3 + TypeScript + Element Plus)。系统覆盖管理员登录鉴权(JWT 双 token 自动刷新)、仪表盘统计、用户管理、商品管理、订单与退款审核、内容审核(穿搭/衣物/衣柜分享)、通知推送,以及超级管理员专属的管理员账号管理,共 9 大功能模块。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **登录与鉴权**:账号密码登录,access/refresh 双 token 管理;access 过期自动用 refresh 换新并重放原请求;登出、修改密码;按 role 区分超管/普通管理员菜单与路由权限
|
||||||
|
- **仪表盘**:统计卡片展示用户总数/今日新增/状态分布、订单状态分布、累计与今日营收/累计退款、待办事项(待处理退款、待审核穿搭、上架商品数),金额以元展示
|
||||||
|
- **用户管理**:分页搜索(昵称/手机号/OpenID/状态)、详情查看(含四类额度)、封禁/解封(临时按天/永久)、额度人工调整、VIP 人工调整,均带原因填写与二次确认
|
||||||
|
- **商品管理**:分页筛选(类型/状态/关键字)、新增、编辑、上下架、软删除;商品类型含会员包/上传包/AI 包/衣柜分享包/搭配分享包,金额按分输入、按元展示
|
||||||
|
- **订单与退款**:订单列表筛选/详情抽屉;退款单列表(状态 0-3 可显式筛选待处理)、审核通过/驳回(必填备注原因)
|
||||||
|
- **内容审核**:穿搭列表筛选/详情(含衣物明细)、审核通过/驳回、封禁/解封;衣物列表/详情;衣柜分享列表/封禁/解封
|
||||||
|
- **通知推送**:单用户系统通知、全量广播(返回发送人数),表单带字数限制校验
|
||||||
|
- **管理员管理(超管专属)**:管理员列表、创建、编辑(昵称/密码/角色/状态)、删除;普通管理员无权限(后端 403,前端同时隐藏入口)
|
||||||
|
- **统一交互规范**:所有业务错误以 body.code 判断并提示;金额统一 `分→元` 格式化;时间戳统一格式化展示;列表统一分页(page/page_size 1~50)
|
||||||
|
|
||||||
|
## 视觉要求
|
||||||
|
|
||||||
|
深色侧边栏 + 浅色内容区的经典管理后台布局;侧边栏按业务域分组菜单并支持折叠;顶部栏含面包屑、管理员信息、修改密码、登出;状态使用彩色 Tag 标签、操作按钮使用文字链接风格;表格斑马纹、hover 高亮;统计卡片带渐变图标底;弹窗表单统一 Element Plus 风格,操作均带确认与结果反馈。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**:Vue 3.5(`<script setup lang="ts">` 组合式 API)+ TypeScript 6.0
|
||||||
|
- **UI 组件库**:Element Plus(表格/表单/分页/对话框/抽屉/消息提示全覆盖)
|
||||||
|
- **路由**:vue-router 4(含全局前置守卫鉴权与角色权限)
|
||||||
|
- **状态管理**:Pinia(auth store 持久化 token/admin_info)
|
||||||
|
- **HTTP**:axios 实例封装(请求拦截注入 Bearer、响应拦截统一 code 处理、11002 自动刷新重放、防并发刷新)
|
||||||
|
- **开发代理**:Vite `server.proxy` 将 `/api` 转发 `http://localhost:8080`,规避 CORS
|
||||||
|
- **格式化**:手写 `formatMoney`(分→元)、`formatDate`(Unix 秒→字符串)工具函数,不引入额外依赖
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### 总体策略
|
||||||
|
|
||||||
|
按「基础骨架 → API 契约层 → 布局与鉴权 → 业务模块页」四阶段推进。所有 API 类型严格对齐后端 DTO 的 json tag;枚举以常量映射表集中管理(数值→文案→Tag 颜色);复用一套「筛选表单 + 表格 + 分页」的页面组织模式,保证 9 个列表页风格与交互一致。
|
||||||
|
|
||||||
|
### 关键技术决策
|
||||||
|
|
||||||
|
- **axios 拦截器设计**:响应 code 非 0 时统一 ElMessage 报错并 reject;HTTP 401 且 code=11002 时,以单例 Promise 防并发调用 refresh 接口(携带旧 access + refresh),成功后更新 token 并重放原请求队列;刷新失败清空会话跳转登录页。其余 401/403/500 直接提示并跳转/返回。
|
||||||
|
- **路由权限**:静态定义全部路由,meta 标记 `requiresAuth`/`superOnly`;守卫中校验 token 与 admin_info.role,超管专属路由对普通管理员重定向到仪表盘并提示无权限。
|
||||||
|
- **金额与时间**:所有金额字段后端为分(int64),输入框以「元」为单位编辑、提交时乘 100 取整,列表用 `formatMoney` 展示;时间戳为 Unix 秒,统一 `formatDate` 输出 `YYYY-MM-DD HH:mm:ss`,0/空值显示「—」。
|
||||||
|
- **枚举映射**:constants.ts 集中维护 status/vip/quota/goods/order/refund/outfit/share/image 等全部枚举的 `{value,label,tagType}` 映射表,组件通过辅助函数取文案与颜色,避免散落魔法数字。
|
||||||
|
- **页面组织模式**:列表页统一「筛选卡片区 + 表格卡片区 + 分页」三段式;写操作(封禁/调整额度/审核等)统一对话框表单 + 提交确认 + 成功后刷新当前页。
|
||||||
|
- **性能**:列表接口后端已分页(每页最多 50 条),前端不做全量拉取;表格列数多时用固定关键列 + 详情抽屉承载次要信息;组件按路由懒加载切分 chunk。
|
||||||
|
|
||||||
|
## 实施细节
|
||||||
|
|
||||||
|
- **依赖安装**:`npm i element-plus vue-router@4 pinia axios` 及 `@element-plus/icons-vue`;main.ts 全量引入 Element Plus(管理后台规模,按需引入收益有限)并注册图标。
|
||||||
|
- **主色定制**:通过 Element Plus CSS 变量(`--el-color-primary` 系列)覆盖为靛蓝色系,与深色侧边栏协调。
|
||||||
|
- **退款状态筛选注意**:`/refunds?status=0` 须显式传 0(待处理),前端下拉用「-1=全部」占位转换,防止丢失。
|
||||||
|
- **refresh-token 注意**:body 必须同时携带 `refresh_token` 与旧 `access_token`(后端强制校验)。
|
||||||
|
- **noUncheckedIndexedAccess 已开启**:数组/对象索引访问需判空处理。
|
||||||
|
- **业务错误提示**:后端多数业务错误 HTTP 仍为 200,必须读取 body.code 分支提示 msg;避免仅按 HTTP 状态判断。
|
||||||
|
- **安全**:token 仅存 localStorage(本场景无 SSR);密码字段不落日志;请求日志不打印 Authorization。
|
||||||
|
- **开发联调**:后端未启动时前端可正常渲染空态;启动后端(`go run .`,默认 8080)后经 Vite 代理联调。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph 前端
|
||||||
|
A[Login 登录页] --> B[AdminLayout 主布局]
|
||||||
|
B --> C[Router 路由 + 守卫]
|
||||||
|
C --> D[业务页面 views]
|
||||||
|
D --> E[api 模块层]
|
||||||
|
E --> F[axios 实例 + 拦截器]
|
||||||
|
F --> G[Pinia auth store]
|
||||||
|
end
|
||||||
|
F -->|/api 经 Vite proxy| H[Go 后端 :8080/api/v1]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **分层**:views(页面)→ api(接口封装,纯函数)→ request(axios 拦截器)→ store(token/管理员信息);类型集中在 types/,枚举与格式化工具在 utils/,与后端契约一一对应。
|
||||||
|
- **数据流**:用户操作 → 页面调用 api → axios 注入 token 发请求 → 响应拦截统一处理 code/401 → 页面更新表格数据/提示结果 → auth store 变更触发路由守卫响应。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
zt-closet-manage-frontend/
|
||||||
|
├── package.json # [MODIFY] 新增 element-plus/vue-router/pinia/axios/@element-plus/icons-vue 依赖
|
||||||
|
├── vite.config.ts # [MODIFY] 增加 server.proxy:/api → http://localhost:8080
|
||||||
|
├── index.html # [MODIFY] 标题改为「智能衣橱管理后台」
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # [MODIFY] 挂载 router/pinia、全量引入 Element Plus 与图标、引入全局样式
|
||||||
|
│ ├── App.vue # [MODIFY] 替换为 <router-view/> 根组件
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ ├── main.css # [MODIFY] 全局样式:布局背景、卡片圆角阴影、滚动条、通用工具类
|
||||||
|
│ │ └── element-theme.css # [NEW] Element Plus 主色变量覆盖(靛蓝系)+ 深色侧边栏配色变量
|
||||||
|
│ ├── router/index.ts # [NEW] 路由表 + 全局守卫(登录态校验、superOnly 角色校验、页面标题)
|
||||||
|
│ ├── stores/auth.ts # [NEW] Pinia auth store:token/refreshToken/adminInfo 持久化、login/logout/refresh 动作
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── request.ts # [NEW] axios 实例:注入 Bearer、code≠0 统一报错、11002 单例刷新+请求队列重放、401/403/500 处理
|
||||||
|
│ │ ├── format.ts # [NEW] formatMoney(分→元)、formatDate(秒→YYYY-MM-DD HH:mm:ss)、formatDateTimeRange 等
|
||||||
|
│ │ └── constants.ts # [NEW] 全量枚举映射:用户状态/VIP/额度/商品/订单/退款/穿搭/分享/图片处理/管理员角色等 → {label,tagType}
|
||||||
|
│ ├── types/
|
||||||
|
│ │ ├── common.ts # [NEW] ApiResponse{code,msg,data,details}、PageQuery、PageResult{total,page,page_size,list} 泛型
|
||||||
|
│ │ ├── auth.ts # [NEW] AdminInfo、AdminLoginResponse、RefreshTokenResponse、改密请求类型
|
||||||
|
│ │ ├── user.ts # [NEW] UserListItem/UserDetailResponse/QuotaItem/封禁/额度/VIP 调整请求类型
|
||||||
|
│ │ ├── goods.ts # [NEW] GoodsItem/GoodsCreateRequest/GoodsUpdateRequest/状态请求类型
|
||||||
|
│ │ ├── order.ts # [NEW] OrderItem/RefundItem/审核请求类型
|
||||||
|
│ │ ├── content.ts # [NEW] OutfitListItem/OutfitDetailResponse/OutfitClothesItem/ClothesItem/ClosetShareItem 及审核/封禁请求
|
||||||
|
│ │ ├── notification.ts # [NEW] 单发/广播请求与响应类型
|
||||||
|
│ │ └── dashboard.ts # [NEW] 仪表盘聚合响应嵌套结构类型
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── auth.ts # [NEW] login/refreshToken/logout/changePassword
|
||||||
|
│ │ ├── dashboard.ts # [NEW] getSummary
|
||||||
|
│ │ ├── user.ts # [NEW] 列表/详情/封禁/解封/额度调整/VIP 调整
|
||||||
|
│ │ ├── goods.ts # [NEW] 列表/创建/更新/上下架/软删
|
||||||
|
│ │ ├── order.ts # [NEW] 订单列表/详情、退款列表/通过/驳回
|
||||||
|
│ │ ├── content.ts # [NEW] 穿搭列表/详情/审核/封禁解封、衣物列表/详情、分享列表/封禁解封
|
||||||
|
│ │ ├── notification.ts # [NEW] 单发/广播
|
||||||
|
│ │ └── admin.ts # [NEW] 管理员列表/创建/更新/删除
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── PaginationBar.vue # [NEW] 通用分页条:v-model 页码/每页条数,联动 emit 刷新,封装 total 显示
|
||||||
|
│ │ └── SearchFormCard.vue # [NEW] 通用筛选卡片:插槽承载筛选控件,查询/重置按钮触发 emit
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── AdminLayout.vue # [NEW] 整体布局:深色可折叠侧边栏(分组菜单、按 role 过滤超管菜单)+ 顶栏 + 内容区
|
||||||
|
│ │ ├── SidebarMenu.vue # [NEW] 侧边栏菜单组件:递归渲染分组路由、折叠态图标提示、active 高亮
|
||||||
|
│ │ └── HeaderBar.vue # [NEW] 顶栏:面包屑、管理员昵称下拉(修改密码对话框/登出)
|
||||||
|
│ └── views/
|
||||||
|
│ ├── login/LoginView.vue # [NEW] 登录页:渐变背景居中卡片、账号密码表单校验、登录后按 role 跳转
|
||||||
|
│ ├── dashboard/DashboardView.vue # [NEW] 仪表盘:四组统计卡片(用户/订单/营收/待办)、金额元格式化、空态处理
|
||||||
|
│ ├── user/UserListView.vue # [NEW] 用户列表:筛选/表格(状态 Tag)/详情抽屉(额度表)/封禁、解封、额度调整、VIP 调整对话框
|
||||||
|
│ ├── goods/GoodsListView.vue # [NEW] 商品列表:筛选/表格/新增编辑对话框(分元输入转换)/上下架/软删确认
|
||||||
|
│ ├── order/OrderListView.vue # [NEW] 订单列表:筛选/表格/详情抽屉(金额与配额快照)
|
||||||
|
│ ├── order/RefundListView.vue # [NEW] 退款列表:状态筛选(含显式待处理)/通过、驳回对话框(必填原因)
|
||||||
|
│ ├── review/OutfitListView.vue # [NEW] 穿搭列表:多条件筛选/审核通过驳回(驳回必填原因)/封禁解封/详情抽屉(衣物明细)
|
||||||
|
│ ├── review/ClothesListView.vue # [NEW] 衣物列表:筛选/详情抽屉(属性字段分栏展示)
|
||||||
|
│ ├── review/ShareListView.vue # [NEW] 衣柜分享列表:筛选/封禁解封
|
||||||
|
│ ├── notification/NotifyView.vue # [NEW] 通知推送:单用户通知与全量广播两个表单卡片、字数限制、广播返回 sent_count 提示
|
||||||
|
│ └── admin/AdminListView.vue # [NEW] 管理员列表(超管):筛选/表格/创建编辑对话框/删除确认/状态禁用提示
|
||||||
|
```
|
||||||
|
|
||||||
|
删除模板遗留文件:`src/components/HelloWorld.vue`、`TheWelcome.vue`、`WelcomeItem.vue`、`src/assets/logo.svg`。
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# zt-closet-manage-frontend
|
||||||
|
|
||||||
|
「纸条衣橱」管理后台前端系统,对接 [zt-closet-manage-backend](https://git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend)(Go + Gin)提供的 `/api/v1` 接口。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 分类 | 技术 |
|
||||||
|
| --- | --- |
|
||||||
|
| 框架 | Vue 3(`<script setup lang="ts">`)+ TypeScript |
|
||||||
|
| 构建 | Vite |
|
||||||
|
| UI | Element Plus(全量引入,靛蓝主题定制) |
|
||||||
|
| 路由 | vue-router 4(全局守卫:登录态 + 超管权限) |
|
||||||
|
| 状态 | Pinia(auth store 持久化 token / 管理员信息) |
|
||||||
|
| HTTP | axios(Bearer 注入、业务码统一处理、11002 过期自动刷新重放) |
|
||||||
|
|
||||||
|
## 功能模块
|
||||||
|
|
||||||
|
- **登录与鉴权**:账号密码登录、JWT 双 token 自动刷新、登出、修改密码
|
||||||
|
- **仪表盘**:用户/订单/营收/待办统计聚合
|
||||||
|
- **用户管理**:分页搜索、详情(四类额度)、封禁/解封、额度/VIP 人工调整
|
||||||
|
- **商品管理**:新增/编辑/上下架/软删除
|
||||||
|
- **订单与退款**:订单列表与详情、退款审核(通过/驳回)
|
||||||
|
- **内容审核**:穿搭审核(通过/驳回/封禁)、衣物列表、衣柜分享封禁
|
||||||
|
- **通知推送**:单用户通知、全量广播
|
||||||
|
- **管理员管理**(超管专属):列表/创建/编辑/删除
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev # 开发(默认 http://localhost:5173)
|
||||||
|
npm run build # 类型检查 + 生产构建
|
||||||
|
```
|
||||||
|
|
||||||
|
开发环境下 `/api` 请求经 Vite 代理转发至 `http://localhost:8080`(后端默认端口),需先启动后端服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../zt-closet-manage-backend && go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # 接口封装(与后端 DTO 一一对应)
|
||||||
|
├── assets/ # 全局样式与 Element Plus 主题变量
|
||||||
|
├── components/ # 通用组件(筛选卡片、分页条)
|
||||||
|
├── layout/ # 后台布局(侧边栏、顶栏)
|
||||||
|
├── router/ # 路由表与全局守卫
|
||||||
|
├── stores/ # Pinia(auth)
|
||||||
|
├── types/ # 类型定义(对齐后端 json tag)
|
||||||
|
├── utils/ # request 封装、格式化、枚举映射
|
||||||
|
└── views/ # 业务页面(按模块懒加载)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
- 金额单位后端为「分」,前端统一按「元」展示与输入(提交时乘 100 取整)
|
||||||
|
- 时间戳为 Unix 秒,统一格式化为 `YYYY-MM-DD HH:mm:ss`
|
||||||
|
- 业务结果以响应体 `code` 为准(多数业务错误 HTTP 仍为 200)
|
||||||
|
- access token 过期(业务码 11002)时自动用 refresh token 换取新 token 并重放原请求
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "zt-closet-manage-frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"element-plus": "^2.14.5",
|
||||||
|
"pinia": "^4.0.3",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"npm-run-all2": "^9.0.2",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.1.5",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.5",
|
||||||
|
"vue-tsc": "^3.3.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.18.0 || >=24.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { httpDelete, httpGet, httpPost, httpPut } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
AdminCreateRequest,
|
||||||
|
AdminInfo,
|
||||||
|
AdminUpdateRequest,
|
||||||
|
} from '@/types/auth'
|
||||||
|
|
||||||
|
export interface AdminListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员分页列表(超管专属) */
|
||||||
|
export function getAdminList(query: AdminListQuery): Promise<PageResult<AdminInfo>> {
|
||||||
|
return httpGet<PageResult<AdminInfo>>('/admins', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建管理员 */
|
||||||
|
export function createAdmin(data: AdminCreateRequest): Promise<AdminInfo> {
|
||||||
|
return httpPost<AdminInfo>('/admins', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新管理员 */
|
||||||
|
export function updateAdmin(id: number, data: AdminUpdateRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/admins/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除管理员(软删) */
|
||||||
|
export function deleteAdmin(id: number): Promise<Record<string, never>> {
|
||||||
|
return httpDelete<Record<string, never>>(`/admins/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { httpPost, httpPut } from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
AdminChangePasswordRequest,
|
||||||
|
AdminLoginRequest,
|
||||||
|
AdminLoginResponse,
|
||||||
|
AdminRefreshTokenRequest,
|
||||||
|
AdminRefreshTokenResponse,
|
||||||
|
} from '@/types/auth'
|
||||||
|
|
||||||
|
/** 管理员登录 */
|
||||||
|
export function login(data: AdminLoginRequest): Promise<AdminLoginResponse> {
|
||||||
|
return httpPost<AdminLoginResponse>('/admin/login', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新双 token */
|
||||||
|
export function refreshToken(data: AdminRefreshTokenRequest): Promise<AdminRefreshTokenResponse> {
|
||||||
|
return httpPost<AdminRefreshTokenResponse>('/admin/refresh-token', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登出(吊销 refresh + 当前 access) */
|
||||||
|
export function logout(refresh_token: string): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>('/admin/logout', { refresh_token })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改自己密码 */
|
||||||
|
export function changePassword(data: AdminChangePasswordRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>('/admin/password', data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
ClosetShareItem,
|
||||||
|
ClosetShareListQuery,
|
||||||
|
ClothesItem,
|
||||||
|
ClothesListQuery,
|
||||||
|
OutfitAuditRequest,
|
||||||
|
OutfitBanRequest,
|
||||||
|
OutfitDetailResponse,
|
||||||
|
OutfitListItem,
|
||||||
|
OutfitListQuery,
|
||||||
|
} from '@/types/content'
|
||||||
|
|
||||||
|
/* ---------------- 穿搭 ---------------- */
|
||||||
|
export function getOutfitList(query: OutfitListQuery): Promise<PageResult<OutfitListItem>> {
|
||||||
|
return httpGet<PageResult<OutfitListItem>>('/outfits', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOutfitDetail(uuid: string): Promise<OutfitDetailResponse> {
|
||||||
|
return httpGet<OutfitDetailResponse>(`/outfits/${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditOutfit(uuid: string, data: OutfitAuditRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/outfits/${uuid}/audit`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function banOutfit(uuid: string, data: OutfitBanRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/outfits/${uuid}/ban`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unbanOutfit(uuid: string): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/outfits/${uuid}/unban`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 衣物 ---------------- */
|
||||||
|
export function getClothesList(query: ClothesListQuery): Promise<PageResult<ClothesItem>> {
|
||||||
|
return httpGet<PageResult<ClothesItem>>('/clothes', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClothesDetail(uuid: string): Promise<ClothesItem> {
|
||||||
|
return httpGet<ClothesItem>(`/clothes/${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 衣柜分享 ---------------- */
|
||||||
|
export function getClosetShareList(query: ClosetShareListQuery): Promise<PageResult<ClosetShareItem>> {
|
||||||
|
return httpGet<PageResult<ClosetShareItem>>('/closet-shares', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function banClosetShare(uuid: string): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/closet-shares/${uuid}/ban`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unbanClosetShare(uuid: string): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/closet-shares/${uuid}/unban`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { httpGet } from '@/utils/request'
|
||||||
|
import type { DashboardSummary, DashboardTrends } from '@/types/dashboard'
|
||||||
|
|
||||||
|
/** 仪表盘核心指标聚合 */
|
||||||
|
export function getDashboardSummary(): Promise<DashboardSummary> {
|
||||||
|
return httpGet<DashboardSummary>('/dashboard/summary')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘近 N 天趋势 */
|
||||||
|
export function getDashboardTrends(days = 30): Promise<DashboardTrends> {
|
||||||
|
return httpGet<DashboardTrends>('/dashboard/trends', { days })
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { httpDelete, httpGet, httpPost, httpPut } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
GoodsCreateRequest,
|
||||||
|
GoodsItem,
|
||||||
|
GoodsListQuery,
|
||||||
|
GoodsStatusRequest,
|
||||||
|
GoodsUpdateRequest,
|
||||||
|
} from '@/types/goods'
|
||||||
|
|
||||||
|
/** 商品分页列表(含下架) */
|
||||||
|
export function getGoodsList(query: GoodsListQuery): Promise<PageResult<GoodsItem>> {
|
||||||
|
return httpGet<PageResult<GoodsItem>>('/goods', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建商品 */
|
||||||
|
export function createGoods(data: GoodsCreateRequest): Promise<GoodsItem> {
|
||||||
|
return httpPost<GoodsItem>('/goods', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新商品(传什么改什么) */
|
||||||
|
export function updateGoods(id: number, data: GoodsUpdateRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/goods/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上下架 */
|
||||||
|
export function updateGoodsStatus(id: number, data: GoodsStatusRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/goods/${id}/status`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 软删除商品 */
|
||||||
|
export function deleteGoods(id: number): Promise<Record<string, never>> {
|
||||||
|
return httpDelete<Record<string, never>>(`/goods/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { httpPost } from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
NotificationBroadcastRequest,
|
||||||
|
NotificationBroadcastResponse,
|
||||||
|
NotificationSendRequest,
|
||||||
|
} from '@/types/notification'
|
||||||
|
|
||||||
|
/** 单用户系统通知 */
|
||||||
|
export function sendNotification(data: NotificationSendRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>('/notifications', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量广播 */
|
||||||
|
export function broadcastNotification(data: NotificationBroadcastRequest): Promise<NotificationBroadcastResponse> {
|
||||||
|
return httpPost<NotificationBroadcastResponse>('/notifications/broadcast', data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
OrderItem,
|
||||||
|
OrderListQuery,
|
||||||
|
RefundAuditRequest,
|
||||||
|
RefundItem,
|
||||||
|
RefundListQuery,
|
||||||
|
} from '@/types/order'
|
||||||
|
|
||||||
|
/** 订单分页列表 */
|
||||||
|
export function getOrderList(query: OrderListQuery): Promise<PageResult<OrderItem>> {
|
||||||
|
return httpGet<PageResult<OrderItem>>('/orders', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单详情 */
|
||||||
|
export function getOrderDetail(orderNo: string): Promise<OrderItem> {
|
||||||
|
return httpGet<OrderItem>(`/orders/${orderNo}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退款单分页列表 */
|
||||||
|
export function getRefundList(query: RefundListQuery): Promise<PageResult<RefundItem>> {
|
||||||
|
return httpGet<PageResult<RefundItem>>('/refunds', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退款审核通过 */
|
||||||
|
export function approveRefund(refundNo: string, data: RefundAuditRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/refunds/${refundNo}/approve`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退款审核驳回 */
|
||||||
|
export function rejectRefund(refundNo: string, data: RefundAuditRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPost<Record<string, never>>(`/refunds/${refundNo}/reject`, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { httpDelete, httpGet, httpPut } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type {
|
||||||
|
UserBanRequest,
|
||||||
|
UserDetailResponse,
|
||||||
|
UserListItem,
|
||||||
|
UserListQuery,
|
||||||
|
UserQuotaAdjustRequest,
|
||||||
|
UserUnbanRequest,
|
||||||
|
UserVIPAdjustRequest,
|
||||||
|
} from '@/types/user'
|
||||||
|
|
||||||
|
/** 用户分页列表 */
|
||||||
|
export function getUserList(query: UserListQuery): Promise<PageResult<UserListItem>> {
|
||||||
|
return httpGet<PageResult<UserListItem>>('/users', { ...query })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户详情(含四类额度) */
|
||||||
|
export function getUserDetail(uuid: string): Promise<UserDetailResponse> {
|
||||||
|
return httpGet<UserDetailResponse>(`/users/${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 封禁用户 */
|
||||||
|
export function banUser(uuid: string, data: UserBanRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/users/${uuid}/ban`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解封用户 */
|
||||||
|
export function unbanUser(uuid: string, data: UserUnbanRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/users/${uuid}/unban`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 额度人工调整 */
|
||||||
|
export function adjustUserQuota(uuid: string, data: UserQuotaAdjustRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/users/${uuid}/quota`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VIP 人工调整 */
|
||||||
|
export function adjustUserVip(uuid: string, data: UserVIPAdjustRequest): Promise<Record<string, never>> {
|
||||||
|
return httpPut<Record<string, never>>(`/users/${uuid}/vip`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回收额度凭证 */
|
||||||
|
export function revokeGrant(uuid: string, grantId: number): Promise<Record<string, never>> {
|
||||||
|
return httpDelete<Record<string, never>>(`/users/${uuid}/grants/${grantId}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* Element Plus 主题定制:靛蓝主色 + 深色侧边栏协调色 */
|
||||||
|
:root {
|
||||||
|
--el-color-primary: #4f6ef7;
|
||||||
|
--el-color-primary-light-3: #7d93f9;
|
||||||
|
--el-color-primary-light-5: #a7b6fb;
|
||||||
|
--el-color-primary-light-7: #d1d9fd;
|
||||||
|
--el-color-primary-light-8: #e4e9fe;
|
||||||
|
--el-color-primary-light-9: #f2f4fe;
|
||||||
|
--el-color-primary-dark-2: #3f58c6;
|
||||||
|
|
||||||
|
--el-border-radius-base: 8px;
|
||||||
|
--el-font-size-base: 14px;
|
||||||
|
|
||||||
|
/* 管理后台侧边栏配色 */
|
||||||
|
--zt-sidebar-bg: #1e2438;
|
||||||
|
--zt-sidebar-bg-light: #272f4a;
|
||||||
|
--zt-sidebar-text: #9aa3c0;
|
||||||
|
--zt-sidebar-active: #4f6ef7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c6cbdd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8aec7;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
'PingFang SC',
|
||||||
|
'Helvetica Neue',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
'Segoe UI',
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2b3044;
|
||||||
|
background: #f4f6fb;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用页面卡片 */
|
||||||
|
.zt-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zt-card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2437;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 金额与数字 */
|
||||||
|
.zt-money {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格操作列 */
|
||||||
|
.zt-table-actions .el-button + .el-button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页右对齐 */
|
||||||
|
.zt-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选区 */
|
||||||
|
.zt-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zt-filter .el-input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zt-filter .el-select {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import type { EChartsOption } from 'echarts'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
option: EChartsOption
|
||||||
|
height?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
height: '300px',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement>()
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chart) return
|
||||||
|
chart.setOption(props.option, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
chart = echarts.init(chartRef.value)
|
||||||
|
render()
|
||||||
|
resizeObserver = new ResizeObserver(() => chart?.resize())
|
||||||
|
resizeObserver.observe(chartRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.option,
|
||||||
|
() => render(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="base-chart" :style="{ height }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-chart {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:page': [value: number]
|
||||||
|
'update:pageSize': [value: number]
|
||||||
|
change: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentPage = computed({
|
||||||
|
get: () => props.page,
|
||||||
|
set: (val: number) => emit('update:page', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentSize = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: (val: number) => emit('update:pageSize', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleChange() {
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<span class="total-text">共 {{ total }} 条</span>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 30, 50]"
|
||||||
|
background
|
||||||
|
layout="sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handleChange"
|
||||||
|
@size-change="handleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: []
|
||||||
|
reset: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-card">
|
||||||
|
<div class="search-fields">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="search-actions">
|
||||||
|
<el-button type="primary" :icon="Search" @click="emit('search')">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="emit('reset')">重置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-fields {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-fields :deep(.el-input),
|
||||||
|
.search-fields :deep(.el-select) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SidebarMenu from './SidebarMenu.vue'
|
||||||
|
import HeaderBar from './HeaderBar.vue'
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
|
||||||
|
function toggleCollapse() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<aside class="layout-aside" :class="{ collapsed }">
|
||||||
|
<div class="aside-brand" :class="{ collapsed }">
|
||||||
|
<div class="brand-icon">
|
||||||
|
<el-icon :size="22"><ShoppingBag /></el-icon>
|
||||||
|
</div>
|
||||||
|
<transition name="fade-text">
|
||||||
|
<span v-show="!collapsed" class="brand-title">纸条衣橱管理后台</span>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
<SidebarMenu :collapsed="collapsed" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="layout-main">
|
||||||
|
<HeaderBar :collapsed="collapsed" @toggle="toggleCollapse" />
|
||||||
|
<main class="layout-content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="page-fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--zt-sidebar-bg);
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside.collapsed {
|
||||||
|
width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-brand {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-brand.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-text-enter-active,
|
||||||
|
.fade-text-leave-active {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.fade-text-enter-from,
|
||||||
|
.fade-text-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-fade-enter-active,
|
||||||
|
.page-fade-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.page-fade-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
.page-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Expand, Fold } from '@element-plus/icons-vue'
|
||||||
|
import { changePassword, logout } from '@/api/auth'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
defineProps<{ collapsed: boolean }>()
|
||||||
|
const emit = defineEmits<{ toggle: [] }>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const displayName = computed(
|
||||||
|
() => authStore.adminInfo?.nickname || authStore.adminInfo?.username || '管理员',
|
||||||
|
)
|
||||||
|
const roleText = computed(() => (authStore.isSuper ? '超级管理员' : '普通管理员'))
|
||||||
|
const pageTitle = computed(() => route.meta.title ?? '')
|
||||||
|
|
||||||
|
/* ---------------- 修改密码 ---------------- */
|
||||||
|
const pwdVisible = ref(false)
|
||||||
|
const pwdFormRef = ref<FormInstance>()
|
||||||
|
const pwdLoading = ref(false)
|
||||||
|
|
||||||
|
const pwdForm = reactive({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const pwdRules: FormRules = {
|
||||||
|
old_password: [
|
||||||
|
{ required: true, message: '请输入原密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
confirm_password: [
|
||||||
|
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (value !== pwdForm.new_password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPwdDialog() {
|
||||||
|
pwdForm.old_password = ''
|
||||||
|
pwdForm.new_password = ''
|
||||||
|
pwdForm.confirm_password = ''
|
||||||
|
pwdVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword() {
|
||||||
|
const valid = await pwdFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
pwdLoading.value = true
|
||||||
|
try {
|
||||||
|
await changePassword({
|
||||||
|
old_password: pwdForm.old_password,
|
||||||
|
new_password: pwdForm.new_password,
|
||||||
|
})
|
||||||
|
ElMessage.success('密码修改成功,请重新登录')
|
||||||
|
pwdVisible.value = false
|
||||||
|
authStore.clearSession()
|
||||||
|
router.replace('/login')
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
pwdLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 登出 ---------------- */
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await logout(authStore.refreshToken)
|
||||||
|
} catch {
|
||||||
|
// 登出失败不阻塞本地清理
|
||||||
|
}
|
||||||
|
authStore.clearSession()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="header-bar">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="collapse-btn" :size="20" @click="emit('toggle')">
|
||||||
|
<Expand v-if="collapsed" />
|
||||||
|
<Fold v-else />
|
||||||
|
</el-icon>
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item>管理后台</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item v-if="pageTitle">{{ pageTitle }}</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dropdown trigger="click">
|
||||||
|
<div class="header-admin">
|
||||||
|
<div class="admin-avatar">
|
||||||
|
{{ displayName.slice(0, 1).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="admin-meta">
|
||||||
|
<div class="admin-name">{{ displayName }}</div>
|
||||||
|
<div class="admin-role">{{ roleText }}</div>
|
||||||
|
</div>
|
||||||
|
<el-icon class="admin-caret"><ArrowDown /></el-icon>
|
||||||
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="openPwdDialog">
|
||||||
|
<el-icon><Key /></el-icon>修改密码
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided @click="handleLogout">
|
||||||
|
<el-icon><SwitchButton /></el-icon>退出登录
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
|
<!-- 修改密码对话框 -->
|
||||||
|
<el-dialog v-model="pwdVisible" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="88px">
|
||||||
|
<el-form-item label="原密码" prop="old_password">
|
||||||
|
<el-input v-model="pwdForm.old_password" type="password" show-password placeholder="请输入原密码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="new_password">
|
||||||
|
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="6~64 位新密码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirm_password">
|
||||||
|
<el-input
|
||||||
|
v-model="pwdForm.confirm_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="pwdVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="pwdLoading" @click="submitPassword">确认修改</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-bar {
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #eef0f6;
|
||||||
|
box-shadow: 0 1px 6px rgba(30, 36, 56, 0.04);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #5d6685;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-admin {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-admin:hover {
|
||||||
|
background: #f4f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-meta {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-caret {
|
||||||
|
color: #9aa3c0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
defineProps<{ collapsed: boolean }>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
interface MenuLeaf {
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
superOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuGroup {
|
||||||
|
groupTitle: string
|
||||||
|
children: MenuLeaf[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const allGroups: MenuGroup[] = [
|
||||||
|
{
|
||||||
|
groupTitle: '总览',
|
||||||
|
children: [{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupTitle: '业务管理',
|
||||||
|
children: [
|
||||||
|
{ path: '/users', title: '用户管理', icon: 'User' },
|
||||||
|
{ path: '/goods', title: '商品管理', icon: 'Goods' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupTitle: '交易',
|
||||||
|
children: [
|
||||||
|
{ path: '/orders', title: '订单管理', icon: 'Tickets' },
|
||||||
|
{ path: '/refunds', title: '退款审核', icon: 'RefreshLeft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupTitle: '内容审核',
|
||||||
|
children: [
|
||||||
|
{ path: '/review/outfits', title: '穿搭审核', icon: 'Collection' },
|
||||||
|
{ path: '/review/clothes', title: '衣物管理', icon: 'ShoppingBag' },
|
||||||
|
{ path: '/review/shares', title: '衣柜分享', icon: 'Share' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupTitle: '运营',
|
||||||
|
children: [{ path: '/notifications', title: '通知推送', icon: 'Bell' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupTitle: '系统',
|
||||||
|
children: [{ path: '/admins', title: '管理员管理', icon: 'Setting', superOnly: true }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 按角色过滤超管专属菜单 */
|
||||||
|
const groups = computed(() =>
|
||||||
|
allGroups
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
children: g.children.filter((c) => !c.superOnly || authStore.isSuper),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.children.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activePath = computed(() => route.path)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-scrollbar class="sidebar-scroll">
|
||||||
|
<el-menu
|
||||||
|
:default-active="activePath"
|
||||||
|
:collapse="collapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
|
router
|
||||||
|
class="sidebar-menu"
|
||||||
|
unique-opened
|
||||||
|
>
|
||||||
|
<template v-for="group in groups" :key="group.groupTitle">
|
||||||
|
<div v-show="!collapsed" class="menu-group-title">{{ group.groupTitle }}</div>
|
||||||
|
<div v-show="collapsed" class="menu-group-divider"></div>
|
||||||
|
<el-menu-item v-for="item in group.children" :key="item.path" :index="item.path">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<template #title>{{ item.title }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</el-scrollbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
border-right: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu:not(.el-menu--collapse) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-group-title {
|
||||||
|
padding: 16px 12px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #5d6685;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-group-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu :deep(.el-menu-item) {
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--zt-sidebar-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu :deep(.el-menu-item:hover) {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||||
|
background: linear-gradient(90deg, rgba(79, 110, 247, 0.9), rgba(139, 92, 246, 0.85));
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(79, 110, 247, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu.el-menu--collapse :deep(.el-menu-item) {
|
||||||
|
padding: 0 !important;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './assets/element-theme.css'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
/** 页面标题(面包屑与 document.title) */
|
||||||
|
title?: string
|
||||||
|
/** 是否需要登录 */
|
||||||
|
requiresAuth?: boolean
|
||||||
|
/** 是否仅超级管理员可见 */
|
||||||
|
superOnly?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/login/LoginView.vue'),
|
||||||
|
meta: { title: '登录' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layout/AdminLayout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||||
|
meta: { title: '仪表盘', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'users',
|
||||||
|
component: () => import('@/views/user/UserListView.vue'),
|
||||||
|
meta: { title: '用户管理', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'goods',
|
||||||
|
name: 'goods',
|
||||||
|
component: () => import('@/views/goods/GoodsListView.vue'),
|
||||||
|
meta: { title: '商品管理', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'orders',
|
||||||
|
name: 'orders',
|
||||||
|
component: () => import('@/views/order/OrderListView.vue'),
|
||||||
|
meta: { title: '订单管理', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'refunds',
|
||||||
|
name: 'refunds',
|
||||||
|
component: () => import('@/views/order/RefundListView.vue'),
|
||||||
|
meta: { title: '退款审核', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'review/outfits',
|
||||||
|
name: 'review-outfits',
|
||||||
|
component: () => import('@/views/review/OutfitListView.vue'),
|
||||||
|
meta: { title: '穿搭审核', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'review/clothes',
|
||||||
|
name: 'review-clothes',
|
||||||
|
component: () => import('@/views/review/ClothesListView.vue'),
|
||||||
|
meta: { title: '衣物管理', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'review/shares',
|
||||||
|
name: 'review-shares',
|
||||||
|
component: () => import('@/views/review/ShareListView.vue'),
|
||||||
|
meta: { title: '衣柜分享', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
name: 'notifications',
|
||||||
|
component: () => import('@/views/notification/NotifyView.vue'),
|
||||||
|
meta: { title: '通知推送', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admins',
|
||||||
|
name: 'admins',
|
||||||
|
component: () => import('@/views/admin/AdminListView.vue'),
|
||||||
|
meta: { title: '管理员管理', requiresAuth: true, superOnly: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const token = authStore.accessToken
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !token) {
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if (to.path === '/login' && token) {
|
||||||
|
return { path: '/dashboard' }
|
||||||
|
}
|
||||||
|
// 超管专属路由:非超管重定向并提示
|
||||||
|
if (to.meta.superOnly && token && !authStore.isSuper) {
|
||||||
|
ElMessage.warning('该功能仅超级管理员可用')
|
||||||
|
return { path: '/dashboard' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = `${to.meta.title} · 纸条衣橱管理后台`
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { AdminInfo } from '@/types/auth'
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = 'zt_admin_access_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'zt_admin_refresh_token'
|
||||||
|
const ADMIN_INFO_KEY = 'zt_admin_info'
|
||||||
|
const TOKEN_EXPIRE_KEY = 'zt_admin_expire_time'
|
||||||
|
|
||||||
|
export interface StoredAuth {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
expireTime: number
|
||||||
|
adminInfo: AdminInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStored(): StoredAuth {
|
||||||
|
return {
|
||||||
|
accessToken: localStorage.getItem(ACCESS_TOKEN_KEY) ?? '',
|
||||||
|
refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY) ?? '',
|
||||||
|
expireTime: Number(localStorage.getItem(TOKEN_EXPIRE_KEY) ?? '0'),
|
||||||
|
adminInfo: parseAdminInfo(localStorage.getItem(ADMIN_INFO_KEY)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdminInfo(raw: string | null): AdminInfo | null {
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as AdminInfo
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const initial = loadStored()
|
||||||
|
const accessToken = ref(initial.accessToken)
|
||||||
|
const refreshToken = ref(initial.refreshToken)
|
||||||
|
const expireTime = ref(initial.expireTime)
|
||||||
|
const adminInfo = ref<AdminInfo | null>(initial.adminInfo)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => accessToken.value !== '')
|
||||||
|
const isSuper = computed(() => adminInfo.value?.role === 1)
|
||||||
|
|
||||||
|
/** 请求层(utils/request.ts)刷新成功后调用,仅同步 token,不改动 adminInfo */
|
||||||
|
function applyTokens(tokens: { access_token: string; refresh_token: string; expire_time: number }) {
|
||||||
|
accessToken.value = tokens.access_token
|
||||||
|
refreshToken.value = tokens.refresh_token
|
||||||
|
expireTime.value = tokens.expire_time
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
|
||||||
|
localStorage.setItem(TOKEN_EXPIRE_KEY, String(tokens.expire_time))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSession(payload: {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expire_time: number
|
||||||
|
admin_info: AdminInfo
|
||||||
|
}) {
|
||||||
|
accessToken.value = payload.access_token
|
||||||
|
refreshToken.value = payload.refresh_token
|
||||||
|
expireTime.value = payload.expire_time
|
||||||
|
adminInfo.value = payload.admin_info
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, payload.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refresh_token)
|
||||||
|
localStorage.setItem(TOKEN_EXPIRE_KEY, String(payload.expire_time))
|
||||||
|
localStorage.setItem(ADMIN_INFO_KEY, JSON.stringify(payload.admin_info))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
accessToken.value = ''
|
||||||
|
refreshToken.value = ''
|
||||||
|
expireTime.value = 0
|
||||||
|
adminInfo.value = null
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||||
|
localStorage.removeItem(ADMIN_INFO_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expireTime,
|
||||||
|
adminInfo,
|
||||||
|
isLoggedIn,
|
||||||
|
isSuper,
|
||||||
|
applyTokens,
|
||||||
|
setSession,
|
||||||
|
clearSession,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/** 管理员信息(与后端 AdminInfo DTO 对齐) */
|
||||||
|
export interface AdminInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
role: number
|
||||||
|
status: number
|
||||||
|
last_login_time: number
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expire_time: number
|
||||||
|
admin_info: AdminInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRefreshTokenRequest {
|
||||||
|
refresh_token: string
|
||||||
|
access_token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRefreshTokenResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expire_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminChangePasswordRequest {
|
||||||
|
old_password: string
|
||||||
|
new_password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCreateRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
nickname?: string
|
||||||
|
role: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUpdateRequest {
|
||||||
|
nickname?: string
|
||||||
|
password?: string
|
||||||
|
role?: number
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** 后端统一响应结构 */
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
details?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页请求参数 */
|
||||||
|
export interface PageQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应结构 */
|
||||||
|
export interface PageResult<T> {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
list: T[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface OutfitListItem {
|
||||||
|
id: number
|
||||||
|
outfit_uuid: string
|
||||||
|
user_id: number
|
||||||
|
outfit_name: string
|
||||||
|
outfit_desc: string
|
||||||
|
scene: string
|
||||||
|
source: number
|
||||||
|
is_public: number
|
||||||
|
audit_status: number
|
||||||
|
status: number
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutfitClothesItem {
|
||||||
|
clothes_uuid: string
|
||||||
|
name: string
|
||||||
|
category_main: string
|
||||||
|
category_sub: string
|
||||||
|
processed_image: string
|
||||||
|
image_process_status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutfitDetailResponse extends OutfitListItem {
|
||||||
|
items: OutfitClothesItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutfitListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
user_id?: number
|
||||||
|
is_public?: number
|
||||||
|
audit_status?: number
|
||||||
|
status?: number
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutfitAuditRequest {
|
||||||
|
result: number
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutfitBanRequest {
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClothesItem {
|
||||||
|
id: number
|
||||||
|
clothes_uuid: string
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
category_main: string
|
||||||
|
category_sub: string
|
||||||
|
style: string
|
||||||
|
color_main: string
|
||||||
|
color_sub: string
|
||||||
|
thickness: string
|
||||||
|
version: string
|
||||||
|
material: string
|
||||||
|
pattern: string
|
||||||
|
sleeve_type: string
|
||||||
|
clothes_type: string
|
||||||
|
neckline_type: string
|
||||||
|
formality: string
|
||||||
|
occasions: string
|
||||||
|
visual_impact: string
|
||||||
|
remark: string
|
||||||
|
processed_image: string
|
||||||
|
image_size: number
|
||||||
|
image_process_status: number
|
||||||
|
fail_reason: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClothesListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
user_id?: number
|
||||||
|
image_process_status?: number
|
||||||
|
category_main?: string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClosetShareItem {
|
||||||
|
id: number
|
||||||
|
share_uuid: string
|
||||||
|
user_id: number
|
||||||
|
share_type: number
|
||||||
|
expire_time: number
|
||||||
|
status: number
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClosetShareListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
user_id?: number
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface DashboardContent {
|
||||||
|
outfit: {
|
||||||
|
total: number
|
||||||
|
pending: number
|
||||||
|
passed: number
|
||||||
|
rejected: number
|
||||||
|
banned: number
|
||||||
|
}
|
||||||
|
clothes: {
|
||||||
|
total: number
|
||||||
|
processing: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
share: {
|
||||||
|
total: number
|
||||||
|
normal: number
|
||||||
|
cancelled: number
|
||||||
|
expired: number
|
||||||
|
banned: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardVIP {
|
||||||
|
member_total: number
|
||||||
|
monthly: number
|
||||||
|
yearly: number
|
||||||
|
trial: number
|
||||||
|
expiring_soon: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsTypeCount {
|
||||||
|
type: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardGoods {
|
||||||
|
total: number
|
||||||
|
on_shelf: number
|
||||||
|
off_shelf: number
|
||||||
|
by_type: GoodsTypeCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardUsage {
|
||||||
|
ai_recommend: number
|
||||||
|
closet_share: number
|
||||||
|
outfit_share: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSocial {
|
||||||
|
cos_image_count: number
|
||||||
|
like_count: number
|
||||||
|
collect_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
users: {
|
||||||
|
total: number
|
||||||
|
today_new: number
|
||||||
|
normal: number
|
||||||
|
temp_ban: number
|
||||||
|
perm_ban: number
|
||||||
|
deactivated: number
|
||||||
|
}
|
||||||
|
orders: {
|
||||||
|
total: number
|
||||||
|
pending: number
|
||||||
|
paid: number
|
||||||
|
cancelled: number
|
||||||
|
expired: number
|
||||||
|
refunded: number
|
||||||
|
}
|
||||||
|
revenue: {
|
||||||
|
total_revenue: number
|
||||||
|
today_revenue: number
|
||||||
|
refund_total: number
|
||||||
|
}
|
||||||
|
todo: {
|
||||||
|
refund_pending: number
|
||||||
|
outfit_pending_audit: number
|
||||||
|
goods_on_shelf: number
|
||||||
|
}
|
||||||
|
content: DashboardContent
|
||||||
|
vip: DashboardVIP
|
||||||
|
goods: DashboardGoods
|
||||||
|
usage: DashboardUsage
|
||||||
|
social: DashboardSocial
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
date: string
|
||||||
|
new_users: number
|
||||||
|
orders: number
|
||||||
|
revenue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTrends {
|
||||||
|
points: TrendPoint[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface GoodsItem {
|
||||||
|
id: number
|
||||||
|
goods_code: string
|
||||||
|
goods_name: string
|
||||||
|
goods_type: number
|
||||||
|
price: number
|
||||||
|
discount_price: number
|
||||||
|
member_days: number
|
||||||
|
upload_quota: number
|
||||||
|
ai_recommend_quota: number
|
||||||
|
closet_share_quota: number
|
||||||
|
outfit_share_quota: number
|
||||||
|
valid_days: number
|
||||||
|
description: string
|
||||||
|
status: number
|
||||||
|
product_id: string
|
||||||
|
product_status: number
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
goods_type?: number
|
||||||
|
status?: number
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsCreateRequest {
|
||||||
|
goods_code: string
|
||||||
|
goods_name: string
|
||||||
|
goods_type: number
|
||||||
|
price: number
|
||||||
|
discount_price: number
|
||||||
|
member_days?: number
|
||||||
|
upload_quota?: number
|
||||||
|
ai_recommend_quota?: number
|
||||||
|
closet_share_quota?: number
|
||||||
|
outfit_share_quota?: number
|
||||||
|
valid_days?: number
|
||||||
|
description?: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsUpdateRequest {
|
||||||
|
goods_name?: string
|
||||||
|
price?: number
|
||||||
|
discount_price?: number
|
||||||
|
member_days?: number
|
||||||
|
upload_quota?: number
|
||||||
|
ai_recommend_quota?: number
|
||||||
|
closet_share_quota?: number
|
||||||
|
outfit_share_quota?: number
|
||||||
|
valid_days?: number
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsStatusRequest {
|
||||||
|
status: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface NotificationSendRequest {
|
||||||
|
user_id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationBroadcastRequest {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationBroadcastResponse {
|
||||||
|
sent_count: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export interface OrderItem {
|
||||||
|
order_no: string
|
||||||
|
user_id: number
|
||||||
|
goods_id: number
|
||||||
|
goods_name: string
|
||||||
|
unit_price: number
|
||||||
|
total_amount: number
|
||||||
|
pay_amount: number
|
||||||
|
pay_channel: number
|
||||||
|
order_status: number
|
||||||
|
transaction_id: string
|
||||||
|
member_days: number
|
||||||
|
upload_quota: number
|
||||||
|
ai_recommend_quota: number
|
||||||
|
closet_share_quota: number
|
||||||
|
outfit_share_quota: number
|
||||||
|
expire_time: number
|
||||||
|
pay_time: number
|
||||||
|
refund_time: number
|
||||||
|
refund_amount: number
|
||||||
|
remark: string
|
||||||
|
xpay_product_id: string
|
||||||
|
xpay_platform: string
|
||||||
|
xpay_env: number
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
order_no?: string
|
||||||
|
user_id?: number
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundItem {
|
||||||
|
refund_no: string
|
||||||
|
order_no: string
|
||||||
|
user_id: number
|
||||||
|
refund_amount: number
|
||||||
|
refund_reason: string
|
||||||
|
status: number
|
||||||
|
apply_time: number
|
||||||
|
success_time: number
|
||||||
|
fail_reason: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: number
|
||||||
|
order_no?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundAuditRequest {
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export interface UserListItem {
|
||||||
|
id: number
|
||||||
|
user_uuid: string
|
||||||
|
username: string
|
||||||
|
avatar: string
|
||||||
|
sex: number
|
||||||
|
phone: string
|
||||||
|
status: number
|
||||||
|
ban_reason: string
|
||||||
|
ban_start: number
|
||||||
|
ban_expire: number
|
||||||
|
vip_type: number
|
||||||
|
vip_expire: number
|
||||||
|
last_login_time: number
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaItem {
|
||||||
|
quota_type: number
|
||||||
|
total: number
|
||||||
|
used: number
|
||||||
|
remain: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserGrantItem {
|
||||||
|
id: number
|
||||||
|
quota_type: number
|
||||||
|
amount: number
|
||||||
|
used: number
|
||||||
|
source: number
|
||||||
|
expire_time: number
|
||||||
|
ref_id: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetailResponse extends UserListItem {
|
||||||
|
quotas: QuotaItem[]
|
||||||
|
grants: UserGrantItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListQuery {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserBanRequest {
|
||||||
|
ban_type: number
|
||||||
|
ban_days?: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUnbanRequest {
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQuotaAdjustRequest {
|
||||||
|
quota_type: number
|
||||||
|
amount: number
|
||||||
|
expire_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserVIPAdjustRequest {
|
||||||
|
vip_type: number
|
||||||
|
vip_days: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { TagProps } from 'element-plus'
|
||||||
|
|
||||||
|
/** 枚举映射项:value=后端数值 label=中文文案 tagType=Tag 颜色 */
|
||||||
|
export interface EnumMapItem {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
tagType?: TagProps['type']
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumMap(items: EnumMapItem[]): Record<number, EnumMapItem> {
|
||||||
|
const map: Record<number, EnumMapItem> = {}
|
||||||
|
for (const item of items) {
|
||||||
|
map[item.value] = item
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 管理员 ---------------- */
|
||||||
|
export const ADMIN_ROLE = enumMap([
|
||||||
|
{ value: 1, label: '超级管理员', tagType: 'danger' },
|
||||||
|
{ value: 2, label: '普通管理员', tagType: 'info' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const ADMIN_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '启用', tagType: 'success' },
|
||||||
|
{ value: 2, label: '禁用', tagType: 'info' },
|
||||||
|
])
|
||||||
|
|
||||||
|
/* ---------------- 用户 ---------------- */
|
||||||
|
export const USER_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '正常', tagType: 'success' },
|
||||||
|
{ value: 2, label: '临时禁用', tagType: 'warning' },
|
||||||
|
{ value: 3, label: '永久禁用', tagType: 'danger' },
|
||||||
|
{ value: 4, label: '已注销', tagType: 'info' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const USER_SEX = enumMap([
|
||||||
|
{ value: 1, label: '男' },
|
||||||
|
{ value: 2, label: '女' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const VIP_TYPE = enumMap([
|
||||||
|
{ value: 1, label: '非会员', tagType: 'info' },
|
||||||
|
{ value: 2, label: '月度会员', tagType: 'warning' },
|
||||||
|
{ value: 3, label: '年度会员', tagType: 'danger' },
|
||||||
|
{ value: 4, label: '体验会员', tagType: 'success' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const QUOTA_TYPE = enumMap([
|
||||||
|
{ value: 1, label: '上传额度' },
|
||||||
|
{ value: 2, label: 'AI 推荐额度' },
|
||||||
|
{ value: 3, label: '社区分享额度' },
|
||||||
|
{ value: 4, label: '衣柜分享额度' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const BAN_TYPE = enumMap([
|
||||||
|
{ value: 1, label: '临时封禁' },
|
||||||
|
{ value: 2, label: '永久封禁' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const GRANT_SOURCE = enumMap([
|
||||||
|
{ value: 1, label: '会员包' },
|
||||||
|
{ value: 2, label: '加量包' },
|
||||||
|
{ value: 3, label: '广告激励' },
|
||||||
|
])
|
||||||
|
|
||||||
|
/* ---------------- 商品 ---------------- */
|
||||||
|
export const GOODS_TYPE = enumMap([
|
||||||
|
{ value: 1, label: '会员包', tagType: 'warning' },
|
||||||
|
{ value: 2, label: '上传包' },
|
||||||
|
{ value: 3, label: 'AI 推荐包' },
|
||||||
|
{ value: 4, label: '衣柜分享包' },
|
||||||
|
{ value: 5, label: '搭配分享包' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const GOODS_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '上架', tagType: 'success' },
|
||||||
|
{ value: 2, label: '下架', tagType: 'info' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const PRODUCT_STATUS = enumMap([
|
||||||
|
{ value: 0, label: '未发布', tagType: 'info' },
|
||||||
|
{ value: 1, label: '发布中', tagType: 'warning' },
|
||||||
|
{ value: 2, label: '已发布', tagType: 'success' },
|
||||||
|
{ value: 3, label: '发布失败', tagType: 'danger' },
|
||||||
|
])
|
||||||
|
|
||||||
|
/* ---------------- 订单 / 退款 ---------------- */
|
||||||
|
export const ORDER_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '待支付', tagType: 'warning' },
|
||||||
|
{ value: 2, label: '已支付', tagType: 'success' },
|
||||||
|
{ value: 3, label: '已取消', tagType: 'info' },
|
||||||
|
{ value: 4, label: '已过期', tagType: 'info' },
|
||||||
|
{ value: 5, label: '已退款', tagType: 'danger' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const PAY_CHANNEL = enumMap([
|
||||||
|
{ value: 1, label: '微信支付' },
|
||||||
|
{ value: 2, label: '微信虚拟支付' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const XPAY_ENV = enumMap([
|
||||||
|
{ value: 0, label: '现网' },
|
||||||
|
{ value: 1, label: '沙箱', tagType: 'warning' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const REFUND_STATUS = enumMap([
|
||||||
|
{ value: 0, label: '待处理', tagType: 'warning' },
|
||||||
|
{ value: 1, label: '退款中', tagType: 'primary' },
|
||||||
|
{ value: 2, label: '退款成功', tagType: 'success' },
|
||||||
|
{ value: 3, label: '退款失败', tagType: 'danger' },
|
||||||
|
])
|
||||||
|
|
||||||
|
/* ---------------- 内容审核 ---------------- */
|
||||||
|
export const OUTFIT_SOURCE = enumMap([
|
||||||
|
{ value: 1, label: '用户创建' },
|
||||||
|
{ value: 2, label: 'AI 推荐', tagType: 'primary' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const OUTFIT_PUBLIC = enumMap([
|
||||||
|
{ value: 1, label: '不公开', tagType: 'info' },
|
||||||
|
{ value: 2, label: '公开', tagType: 'success' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const OUTFIT_AUDIT_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '待审核', tagType: 'warning' },
|
||||||
|
{ value: 2, label: '已通过', tagType: 'success' },
|
||||||
|
{ value: 3, label: '未通过', tagType: 'danger' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const OUTFIT_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '正常', tagType: 'success' },
|
||||||
|
{ value: 2, label: '已封禁', tagType: 'danger' },
|
||||||
|
{ value: 3, label: '待用户保存', tagType: 'warning' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const SHARE_TYPE = enumMap([
|
||||||
|
{ value: 1, label: '部分衣物' },
|
||||||
|
{ value: 2, label: '全量快照' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const SHARE_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '正常', tagType: 'success' },
|
||||||
|
{ value: 2, label: '已取消', tagType: 'info' },
|
||||||
|
{ value: 3, label: '已过期', tagType: 'info' },
|
||||||
|
{ value: 4, label: '已封禁', tagType: 'danger' },
|
||||||
|
])
|
||||||
|
|
||||||
|
export const IMAGE_PROCESS_STATUS = enumMap([
|
||||||
|
{ value: 1, label: '抠图中', tagType: 'warning' },
|
||||||
|
{ value: 2, label: '分类中', tagType: 'warning' },
|
||||||
|
{ value: 3, label: '排队中', tagType: 'info' },
|
||||||
|
{ value: 4, label: '处理成功', tagType: 'success' },
|
||||||
|
{ value: 5, label: '处理失败', tagType: 'danger' },
|
||||||
|
])
|
||||||
|
|
||||||
|
/* ---------------- 工具函数 ---------------- */
|
||||||
|
|
||||||
|
/** 根据枚举表取文案,未匹配返回 '-' */
|
||||||
|
export function enumLabel(map: Record<number, EnumMapItem>, value?: number | null): string {
|
||||||
|
if (value === undefined || value === null) return '-'
|
||||||
|
const item = map[value]
|
||||||
|
return item ? item.label : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据枚举表取 Tag 颜色 */
|
||||||
|
export function enumTag(map: Record<number, EnumMapItem>, value?: number | null): TagProps['type'] {
|
||||||
|
if (value === undefined || value === null) return 'info'
|
||||||
|
const item = map[value]
|
||||||
|
return item?.tagType ?? 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 枚举表转下拉选项(数字值) */
|
||||||
|
export function enumOptions(map: Record<number, EnumMapItem>): { value: number; label: string }[] {
|
||||||
|
return Object.values(map).map((item) => ({ value: item.value, label: item.label }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** 金额:分 → 元字符串(保留两位小数) */
|
||||||
|
export function formatMoney(cents?: number | null): string {
|
||||||
|
if (cents === undefined || cents === null || Number.isNaN(cents)) return '-'
|
||||||
|
const yuan = cents / 100
|
||||||
|
return `${yuan.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 金额:元(字符串) → 分(整数),非法输入返回 0 */
|
||||||
|
export function yuanToCents(yuan: number | string): number {
|
||||||
|
const num = typeof yuan === 'string' ? Number.parseFloat(yuan) : yuan
|
||||||
|
if (Number.isNaN(num) || num < 0) return 0
|
||||||
|
return Math.round(num * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间戳(Unix 秒) → YYYY-MM-DD HH:mm:ss;0/空显示 '-' */
|
||||||
|
export function formatDate(seconds?: number | null): string {
|
||||||
|
if (!seconds || seconds <= 0) return '-'
|
||||||
|
const d = new Date(seconds * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间戳(Unix 秒) → YYYY-MM-DD;0/空显示 '-' */
|
||||||
|
export function formatDateOnly(seconds?: number | null): string {
|
||||||
|
if (!seconds || seconds <= 0) return '-'
|
||||||
|
const d = new Date(seconds * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空字符串显示为 '-' */
|
||||||
|
export function formatText(value?: string | null): string {
|
||||||
|
return value === undefined || value === null || value === '' ? '-' : value
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import axios, { AxiosError, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
|
/** 业务错误码:access token 过期(HTTP 401) */
|
||||||
|
const CODE_TOKEN_TIMEOUT = 11002
|
||||||
|
|
||||||
|
/** 标记已尝试过 token 刷新的配置 */
|
||||||
|
type RetriableConfig = InternalAxiosRequestConfig & { _retried?: boolean }
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------------- 请求拦截:注入 Bearer token ---------------- */
|
||||||
|
request.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('zt_admin_access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------------- 响应拦截:业务码统一处理 + 双 token 刷新重放 ---------------- */
|
||||||
|
let refreshingPromise: Promise<string | null> | null = null
|
||||||
|
|
||||||
|
/** 调用刷新接口换取新双 token;成功返回新 access token,失败返回 null */
|
||||||
|
function refreshTokens(): Promise<string | null> {
|
||||||
|
const refreshToken = localStorage.getItem('zt_admin_refresh_token')
|
||||||
|
const oldAccessToken = localStorage.getItem('zt_admin_access_token')
|
||||||
|
if (!refreshToken || !oldAccessToken) return Promise.resolve(null)
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.post<ApiResponse<{ access_token: string; refresh_token: string; expire_time: number }>>(
|
||||||
|
'/api/v1/admin/refresh-token',
|
||||||
|
{ refresh_token: refreshToken, access_token: oldAccessToken },
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.data.code !== 0 || !res.data.data) return null
|
||||||
|
const tokens = res.data.data
|
||||||
|
localStorage.setItem('zt_admin_access_token', tokens.access_token)
|
||||||
|
localStorage.setItem('zt_admin_refresh_token', tokens.refresh_token)
|
||||||
|
localStorage.setItem('zt_admin_expire_time', String(tokens.expire_time))
|
||||||
|
return tokens.access_token
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
localStorage.removeItem('zt_admin_access_token')
|
||||||
|
localStorage.removeItem('zt_admin_refresh_token')
|
||||||
|
localStorage.removeItem('zt_admin_expire_time')
|
||||||
|
localStorage.removeItem('zt_admin_info')
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response: AxiosResponse): AxiosResponse | Promise<AxiosResponse> => {
|
||||||
|
const body = response.data as ApiResponse<unknown> | undefined
|
||||||
|
if (body && typeof body.code === 'number') {
|
||||||
|
// 业务成功:剥离包装,直接返回 data
|
||||||
|
if (body.code === 0) {
|
||||||
|
response.data = body.data
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
// access 过期:尝试刷新并重放
|
||||||
|
const retriable = response.config as RetriableConfig
|
||||||
|
if (body.code === CODE_TOKEN_TIMEOUT && !retriable._retried) {
|
||||||
|
retriable._retried = true
|
||||||
|
return handleTokenTimeout(retriable)
|
||||||
|
}
|
||||||
|
// 其余业务错误:提示后拒绝
|
||||||
|
const msg = buildErrorMessage(body)
|
||||||
|
ElMessage.error(msg)
|
||||||
|
return Promise.reject(new ApiBizError(body.code, msg, body.details))
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||||
|
const body = error.response?.data
|
||||||
|
const status = error.response?.status
|
||||||
|
const code = body?.code
|
||||||
|
|
||||||
|
// access 过期(HTTP 401 且业务码 11002)
|
||||||
|
if (status === 401 && code === CODE_TOKEN_TIMEOUT && error.config) {
|
||||||
|
const retriable = error.config as RetriableConfig
|
||||||
|
if (!retriable._retried) {
|
||||||
|
retriable._retried = true
|
||||||
|
return handleTokenTimeout(retriable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 其余 401:会话失效,跳登录
|
||||||
|
if (status === 401) {
|
||||||
|
ElMessage.error(body?.msg ?? '登录已失效,请重新登录')
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
// 403 无权限
|
||||||
|
if (status === 403) {
|
||||||
|
ElMessage.error(body?.msg ?? '无权限执行该操作')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
// 系统错误 5xx / 限流
|
||||||
|
if (status && status >= 500) {
|
||||||
|
ElMessage.error(body?.msg ?? '系统繁忙,请稍后重试')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
if (body && typeof body.code === 'number') {
|
||||||
|
const msg = buildErrorMessage(body)
|
||||||
|
ElMessage.error(msg)
|
||||||
|
return Promise.reject(new ApiBizError(body.code, msg, body.details))
|
||||||
|
}
|
||||||
|
ElMessage.error('网络异常,请检查网络连接或后端服务是否启动')
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 统一错误文案:优先拼接 details 中的具体校验信息 */
|
||||||
|
function buildErrorMessage(body: ApiResponse<unknown>): string {
|
||||||
|
if (body.details && body.details.length > 0) {
|
||||||
|
return body.details.join(';')
|
||||||
|
}
|
||||||
|
return body.msg || '操作失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** token 过期处理:单例刷新,所有并发请求等待同一个刷新结果后重放 */
|
||||||
|
function handleTokenTimeout(config: RetriableConfig): Promise<AxiosResponse> {
|
||||||
|
if (!refreshingPromise) {
|
||||||
|
refreshingPromise = refreshTokens()
|
||||||
|
}
|
||||||
|
return refreshingPromise.then((newToken) => {
|
||||||
|
if (newToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return request(config)
|
||||||
|
}
|
||||||
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(new Error('token refresh failed'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务错误对象,携带后端 code 与 details */
|
||||||
|
export class ApiBizError extends Error {
|
||||||
|
code: number
|
||||||
|
details?: string[]
|
||||||
|
constructor(code: number, message: string, details?: string[]) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiBizError'
|
||||||
|
this.code = code
|
||||||
|
this.details = details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 响应拦截器已将包装剥离为 data,此处直接取出业务数据 */
|
||||||
|
function unwrap<T>(p: Promise<AxiosResponse>): Promise<T> {
|
||||||
|
return p.then((res) => res.data as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 GET 请求(返回 data 部分) */
|
||||||
|
export function httpGet<T>(url: string, params?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return unwrap<T>(request.get(url, { params, ...config }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 POST 请求 */
|
||||||
|
export function httpPost<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return unwrap<T>(request.post(url, data, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 PUT 请求 */
|
||||||
|
export function httpPut<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return unwrap<T>(request.put(url, data, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 DELETE 请求 */
|
||||||
|
export function httpDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return unwrap<T>(request.delete(url, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default request
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { createAdmin, deleteAdmin, getAdminList, updateAdmin } from '@/api/admin'
|
||||||
|
import type { AdminInfo } from '@/types/auth'
|
||||||
|
import { ADMIN_ROLE, ADMIN_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||||
|
import { formatDate, formatText } from '@/utils/format'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<AdminInfo[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAdminList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
status: query.status,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.status = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 创建 / 编辑 ---------------- */
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
const editId = ref<number | null>(null)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
nickname: '',
|
||||||
|
role: 2,
|
||||||
|
status: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 32, message: '账号长度 3~32 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (editId.value === null) {
|
||||||
|
if (!value) return callback(new Error('请输入初始密码'))
|
||||||
|
if (value.length < 6 || value.length > 64) return callback(new Error('密码长度 6~64 位'))
|
||||||
|
} else if (value && (value.length < 6 || value.length > 64)) {
|
||||||
|
return callback(new Error('密码长度 6~64 位'))
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nickname: [{ max: 32, message: '昵称不超过 32 字', trigger: 'blur' }],
|
||||||
|
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editId.value = null
|
||||||
|
Object.assign(form, { username: '', password: '', nickname: '', role: 2, status: 1 })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: AdminInfo) {
|
||||||
|
editId.value = row.id
|
||||||
|
Object.assign(form, {
|
||||||
|
username: row.username,
|
||||||
|
password: '',
|
||||||
|
nickname: row.nickname,
|
||||||
|
role: row.role,
|
||||||
|
status: row.status,
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
if (editId.value === null) {
|
||||||
|
await createAdmin({
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
nickname: form.nickname || undefined,
|
||||||
|
role: form.role,
|
||||||
|
})
|
||||||
|
ElMessage.success('管理员创建成功')
|
||||||
|
} else {
|
||||||
|
await updateAdmin(editId.value, {
|
||||||
|
nickname: form.nickname || undefined,
|
||||||
|
password: form.password || undefined,
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
})
|
||||||
|
ElMessage.success('管理员更新成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 删除 ---------------- */
|
||||||
|
async function handleDelete(row: AdminInfo) {
|
||||||
|
if (row.id === authStore.adminInfo?.id) {
|
||||||
|
ElMessage.warning('不能删除自己的账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`删除后该管理员将无法登录(软删除),确认删除「${row.username}」吗?`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await deleteAdmin(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-select v-model="query.status" placeholder="账号状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(ADMIN_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreate">新增管理员</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="username" label="登录账号" min-width="130" />
|
||||||
|
<el-table-column label="昵称" min-width="120">
|
||||||
|
<template #default="{ row }: { row: AdminInfo }">{{ formatText(row.nickname) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="角色" width="110">
|
||||||
|
<template #default="{ row }: { row: AdminInfo }">
|
||||||
|
<el-tag :type="enumTag(ADMIN_ROLE, row.role)" size="small">
|
||||||
|
{{ enumLabel(ADMIN_ROLE, row.role) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }: { row: AdminInfo }">
|
||||||
|
<el-tag :type="enumTag(ADMIN_STATUS, row.status)" size="small">
|
||||||
|
{{ enumLabel(ADMIN_STATUS, row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后登录" width="150">
|
||||||
|
<template #default="{ row }: { row: AdminInfo }">{{ formatDate(row.last_login_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="150">
|
||||||
|
<template #default="{ row }: { row: AdminInfo }">{{ formatDate(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }: { row: AdminInfo }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="row.id === authStore.adminInfo?.id"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无管理员数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建 / 编辑对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editId === null ? '新增管理员' : '编辑管理员'"
|
||||||
|
width="460px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
|
||||||
|
<el-form-item label="登录账号" prop="username">
|
||||||
|
<el-input v-model="form.username" :disabled="editId !== null" placeholder="3~32 位账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
:placeholder="editId === null ? '初始密码(6~64 位)' : '留空则不修改密码'"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="昵称" prop="nickname">
|
||||||
|
<el-input v-model="form.nickname" maxlength="32" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色" prop="role">
|
||||||
|
<el-select v-model="form.role" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(ADMIN_ROLE)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="editId !== null" label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="form.status">
|
||||||
|
<el-radio v-for="opt in enumOptions(ADMIN_STATUS)" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="submitForm">
|
||||||
|
{{ editId === null ? '创建' : '保存' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import BaseChart from '@/components/BaseChart.vue'
|
||||||
|
import { getDashboardSummary, getDashboardTrends } from '@/api/dashboard'
|
||||||
|
import type { DashboardSummary, DashboardTrends } from '@/types/dashboard'
|
||||||
|
import { formatMoney } from '@/utils/format'
|
||||||
|
import { GOODS_TYPE, enumLabel } from '@/utils/constants'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const summary = ref<DashboardSummary | null>(null)
|
||||||
|
const trendDays = ref<7 | 30>(7)
|
||||||
|
const trend = ref<DashboardTrends | null>(null)
|
||||||
|
|
||||||
|
async function fetchSummary() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
summary.value = await getDashboardSummary()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTrends() {
|
||||||
|
try {
|
||||||
|
trend.value = await getDashboardTrends(trendDays.value)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSummary()
|
||||||
|
fetchTrends()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(trendDays, fetchTrends)
|
||||||
|
|
||||||
|
/* ---------- 图表配置 ---------- */
|
||||||
|
|
||||||
|
function pieOption(
|
||||||
|
data: { name: string; value: number }[],
|
||||||
|
colors: string[],
|
||||||
|
): EChartsOption {
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
icon: 'circle',
|
||||||
|
itemWidth: 8,
|
||||||
|
itemHeight: 8,
|
||||||
|
textStyle: { color: '#5d6685', fontSize: 12 },
|
||||||
|
},
|
||||||
|
color: colors,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['48%', '70%'],
|
||||||
|
center: ['50%', '42%'],
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: false } },
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendOption = computed<EChartsOption>(() => {
|
||||||
|
const points = trend.value?.points ?? []
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: {
|
||||||
|
data: ['新增用户', '订单量', '营收(元)'],
|
||||||
|
bottom: 0,
|
||||||
|
icon: 'roundRect',
|
||||||
|
itemWidth: 10,
|
||||||
|
itemHeight: 10,
|
||||||
|
textStyle: { color: '#5d6685' },
|
||||||
|
},
|
||||||
|
color: ['#4f6ef7', '#22c55e', '#f59e0b'],
|
||||||
|
grid: { left: 56, right: 64, top: 40, bottom: 44 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: points.map((p) => p.date.slice(5)),
|
||||||
|
axisLine: { lineStyle: { color: '#e5e8f2' } },
|
||||||
|
axisLabel: { color: '#9aa3c0' },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '数量',
|
||||||
|
minInterval: 1,
|
||||||
|
nameTextStyle: { color: '#9aa3c0' },
|
||||||
|
axisLabel: { color: '#9aa3c0' },
|
||||||
|
splitLine: { lineStyle: { color: '#eef1f8' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '营收(元)',
|
||||||
|
nameTextStyle: { color: '#9aa3c0' },
|
||||||
|
axisLabel: { color: '#9aa3c0' },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '新增用户',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
data: points.map((p) => p.new_users),
|
||||||
|
areaStyle: { opacity: 0.08 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '订单量',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
data: points.map((p) => p.orders),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '营收(元)',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: points.map((p) => +(p.revenue / 100).toFixed(2)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const outfitPieOption = computed<EChartsOption>(() => {
|
||||||
|
const o = summary.value?.content.outfit
|
||||||
|
return pieOption(
|
||||||
|
[
|
||||||
|
{ name: '待审核', value: o?.pending ?? 0 },
|
||||||
|
{ name: '已通过', value: o?.passed ?? 0 },
|
||||||
|
{ name: '未通过', value: o?.rejected ?? 0 },
|
||||||
|
],
|
||||||
|
['#f59e0b', '#22c55e', '#ef4444'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clothesPieOption = computed<EChartsOption>(() => {
|
||||||
|
const c = summary.value?.content.clothes
|
||||||
|
return pieOption(
|
||||||
|
[
|
||||||
|
{ name: '处理中', value: c?.processing ?? 0 },
|
||||||
|
{ name: '成功', value: c?.success ?? 0 },
|
||||||
|
{ name: '失败', value: c?.failed ?? 0 },
|
||||||
|
],
|
||||||
|
['#06b6d4', '#22c55e', '#ef4444'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sharePieOption = computed<EChartsOption>(() => {
|
||||||
|
const s = summary.value?.content.share
|
||||||
|
return pieOption(
|
||||||
|
[
|
||||||
|
{ name: '正常', value: s?.normal ?? 0 },
|
||||||
|
{ name: '已取消', value: s?.cancelled ?? 0 },
|
||||||
|
{ name: '已过期', value: s?.expired ?? 0 },
|
||||||
|
{ name: '已封禁', value: s?.banned ?? 0 },
|
||||||
|
],
|
||||||
|
['#22c55e', '#9aa3c0', '#cbd5e1', '#ef4444'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const vipPieOption = computed<EChartsOption>(() => {
|
||||||
|
const v = summary.value?.vip
|
||||||
|
return pieOption(
|
||||||
|
[
|
||||||
|
{ name: '月度会员', value: v?.monthly ?? 0 },
|
||||||
|
{ name: '年度会员', value: v?.yearly ?? 0 },
|
||||||
|
{ name: '体验会员', value: v?.trial ?? 0 },
|
||||||
|
],
|
||||||
|
['#4f6ef7', '#8b5cf6', '#22c55e'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const goodsTypePieOption = computed<EChartsOption>(() => {
|
||||||
|
const list = summary.value?.goods.by_type ?? []
|
||||||
|
return pieOption(
|
||||||
|
list.map((item) => ({ name: enumLabel(GOODS_TYPE, item.type), value: item.count })),
|
||||||
|
['#4f6ef7', '#06b6d4', '#8b5cf6', '#f59e0b', '#22c55e'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="loading" class="dashboard">
|
||||||
|
<!-- 欢迎横幅 -->
|
||||||
|
<div class="hero-banner">
|
||||||
|
<div>
|
||||||
|
<h2 class="hero-title">纸条衣橱 · 运营总览</h2>
|
||||||
|
<p class="hero-sub">掌握用户、交易与内容审核的核心数据动态</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-todo">
|
||||||
|
<div class="todo-item">
|
||||||
|
<span class="todo-num">{{ summary?.todo.refund_pending ?? 0 }}</span>
|
||||||
|
<span class="todo-label">待处理退款</span>
|
||||||
|
</div>
|
||||||
|
<div class="todo-divider"></div>
|
||||||
|
<div class="todo-item">
|
||||||
|
<span class="todo-num">{{ summary?.todo.outfit_pending_audit ?? 0 }}</span>
|
||||||
|
<span class="todo-label">待审核穿搭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 营收卡片 -->
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-card revenue-card">
|
||||||
|
<div class="stat-icon icon-revenue"><el-icon :size="22"><Money /></el-icon></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-label">累计营收(元)</div>
|
||||||
|
<div class="stat-value">{{ formatMoney(summary?.revenue.total_revenue) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon icon-today"><el-icon :size="22"><TrendCharts /></el-icon></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-label">今日营收(元)</div>
|
||||||
|
<div class="stat-value">{{ formatMoney(summary?.revenue.today_revenue) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon icon-refund"><el-icon :size="22"><RefreshLeft /></el-icon></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-label">累计退款(元)</div>
|
||||||
|
<div class="stat-value">{{ formatMoney(summary?.revenue.refund_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon icon-shelf"><el-icon :size="22"><ShoppingCart /></el-icon></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-label">上架商品数</div>
|
||||||
|
<div class="stat-value">{{ summary?.todo.goods_on_shelf ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 今日消耗与内容互动 -->
|
||||||
|
<div class="stat-row two-col">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">今日消耗</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat-grid">
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value accent-blue">{{ summary?.usage.ai_recommend ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">AI 推荐</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value">{{ summary?.usage.closet_share ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">衣柜分享</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value">{{ summary?.usage.outfit_share ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">社区分享</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">内容互动</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat-grid">
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value accent-green">{{ summary?.social.cos_image_count ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">COS 总图数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value accent-warn">{{ summary?.social.like_count ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">点赞数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value">{{ summary?.social.collect_count ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">收藏数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据趋势 -->
|
||||||
|
<div class="panel trend-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">数据趋势</span>
|
||||||
|
<el-radio-group v-model="trendDays" size="small">
|
||||||
|
<el-radio-button :value="7">近 7 天</el-radio-button>
|
||||||
|
<el-radio-button :value="30">近 30 天</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<BaseChart :option="trendOption" height="320px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 三列分布 -->
|
||||||
|
<div class="stat-row three-col">
|
||||||
|
<!-- 内容审核概况 -->
|
||||||
|
<div class="panel dist-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">内容审核概况</span>
|
||||||
|
<el-tag size="small" type="warning" effect="light">待审穿搭 {{ summary?.content.outfit.pending ?? 0 }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="audit-block">
|
||||||
|
<div class="audit-title">穿搭审核</div>
|
||||||
|
<BaseChart :option="outfitPieOption" height="120px" />
|
||||||
|
</div>
|
||||||
|
<div class="audit-block">
|
||||||
|
<div class="audit-title">衣物图片处理</div>
|
||||||
|
<BaseChart :option="clothesPieOption" height="120px" />
|
||||||
|
</div>
|
||||||
|
<div class="audit-block">
|
||||||
|
<div class="audit-title">衣柜分享</div>
|
||||||
|
<BaseChart :option="sharePieOption" height="120px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIP 会员概况 -->
|
||||||
|
<div class="panel dist-panel dist-panel--center">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">VIP 会员概况</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat-grid">
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value">{{ summary?.vip.member_total ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">有效会员</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value accent-warn">{{ summary?.vip.expiring_soon ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">7 天内到期</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BaseChart :option="vipPieOption" height="220px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品分类统计 -->
|
||||||
|
<div class="panel dist-panel dist-panel--center">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">商品分类统计</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat-grid three">
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value">{{ summary?.goods.total ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value accent-green">{{ summary?.goods.on_shelf ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">上架</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-value accent-gray">{{ summary?.goods.off_shelf ?? 0 }}</div>
|
||||||
|
<div class="mini-stat-label">下架</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BaseChart :option="goodsTypePieOption" height="220px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-row">
|
||||||
|
<!-- 用户分布 -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">用户概况</span>
|
||||||
|
<el-tag size="small" type="primary" effect="light">今日新增 {{ summary?.users.today_new ?? 0 }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="panel-main-number">{{ summary?.users.total ?? 0 }}</div>
|
||||||
|
<div class="panel-sub">用户总数</div>
|
||||||
|
<div class="dist-list">
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-green"></span>
|
||||||
|
<span class="dist-label">正常</span>
|
||||||
|
<span class="dist-value">{{ summary?.users.normal ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-orange"></span>
|
||||||
|
<span class="dist-label">临时禁用</span>
|
||||||
|
<span class="dist-value">{{ summary?.users.temp_ban ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-red"></span>
|
||||||
|
<span class="dist-label">永久禁用</span>
|
||||||
|
<span class="dist-value">{{ summary?.users.perm_ban ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-gray"></span>
|
||||||
|
<span class="dist-label">已注销</span>
|
||||||
|
<span class="dist-value">{{ summary?.users.deactivated ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 订单分布 -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">订单概况</span>
|
||||||
|
<el-tag size="small" type="success" effect="light">已支付 {{ summary?.orders.paid ?? 0 }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="panel-main-number">{{ summary?.orders.total ?? 0 }}</div>
|
||||||
|
<div class="panel-sub">订单总数</div>
|
||||||
|
<div class="dist-list">
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-orange"></span>
|
||||||
|
<span class="dist-label">待支付</span>
|
||||||
|
<span class="dist-value">{{ summary?.orders.pending ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-blue"></span>
|
||||||
|
<span class="dist-label">已取消</span>
|
||||||
|
<span class="dist-value">{{ summary?.orders.cancelled ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-gray"></span>
|
||||||
|
<span class="dist-label">已过期</span>
|
||||||
|
<span class="dist-value">{{ summary?.orders.expired ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dist-item">
|
||||||
|
<span class="dist-dot dot-red"></span>
|
||||||
|
<span class="dist-label">已退款</span>
|
||||||
|
<span class="dist-value">{{ summary?.orders.refunded ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(120deg, #4f6ef7 0%, #6a5cf0 55%, #8b5cf6 100%);
|
||||||
|
box-shadow: 0 10px 26px rgba(79, 110, 247, 0.32);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-todo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-num {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-col {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||||
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 22px rgba(30, 36, 56, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-revenue {
|
||||||
|
background: linear-gradient(135deg, #4f6ef7, #6a5cf0);
|
||||||
|
}
|
||||||
|
.icon-today {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
}
|
||||||
|
.icon-refund {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||||
|
}
|
||||||
|
.icon-shelf {
|
||||||
|
background: linear-gradient(135deg, #06b6d4, #0ea5e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2437;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-panel {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-panel--center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-main-number {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2437;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-block {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-block:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5d6685;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
flex: 1;
|
||||||
|
background: #f7f9fd;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2437;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-value.accent-warn {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-value.accent-green {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-value.accent-gray {
|
||||||
|
color: #9aa3c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-value.accent-blue {
|
||||||
|
color: #4f6ef7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5d6685;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-green {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
.dot-orange {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
.dot-red {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
.dot-blue {
|
||||||
|
background: #4f6ef7;
|
||||||
|
}
|
||||||
|
.dot-gray {
|
||||||
|
background: #9aa3c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-value {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2437;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { createGoods, deleteGoods, getGoodsList, updateGoods, updateGoodsStatus } from '@/api/goods'
|
||||||
|
import type { GoodsItem } from '@/types/goods'
|
||||||
|
import { GOODS_STATUS, GOODS_TYPE, PRODUCT_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||||
|
import { formatDate, formatMoney, formatText, yuanToCents } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<GoodsItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
goods_type: undefined as number | undefined,
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGoodsList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
goods_type: query.goods_type,
|
||||||
|
status: query.status,
|
||||||
|
keyword: query.keyword || undefined,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.goods_type = undefined
|
||||||
|
query.status = undefined
|
||||||
|
query.keyword = ''
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 新增 / 编辑 ---------------- */
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
const editId = ref<number | null>(null)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
interface GoodsForm {
|
||||||
|
goods_code: string
|
||||||
|
goods_name: string
|
||||||
|
goods_type: number
|
||||||
|
price: number
|
||||||
|
discount_price: number
|
||||||
|
member_days: number
|
||||||
|
upload_quota: number
|
||||||
|
ai_recommend_quota: number
|
||||||
|
closet_share_quota: number
|
||||||
|
outfit_share_quota: number
|
||||||
|
valid_days: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<GoodsForm>({
|
||||||
|
goods_code: '',
|
||||||
|
goods_name: '',
|
||||||
|
goods_type: 1,
|
||||||
|
price: 0,
|
||||||
|
discount_price: 0,
|
||||||
|
member_days: 0,
|
||||||
|
upload_quota: 0,
|
||||||
|
ai_recommend_quota: 0,
|
||||||
|
closet_share_quota: 0,
|
||||||
|
outfit_share_quota: 0,
|
||||||
|
valid_days: 0,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
goods_code: [
|
||||||
|
{ required: true, message: '请输入商品编码', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '编码不超过 64 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
goods_name: [
|
||||||
|
{ required: true, message: '请输入商品名称', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '名称不超过 64 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
goods_type: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
|
||||||
|
price: [{ required: true, message: '请输入原价', trigger: 'blur' }],
|
||||||
|
discount_price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editId.value = null
|
||||||
|
Object.assign(form, {
|
||||||
|
goods_code: '',
|
||||||
|
goods_name: '',
|
||||||
|
goods_type: 1,
|
||||||
|
price: 0,
|
||||||
|
discount_price: 0,
|
||||||
|
member_days: 0,
|
||||||
|
upload_quota: 0,
|
||||||
|
ai_recommend_quota: 0,
|
||||||
|
closet_share_quota: 0,
|
||||||
|
outfit_share_quota: 0,
|
||||||
|
valid_days: 0,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: GoodsItem) {
|
||||||
|
editId.value = row.id
|
||||||
|
Object.assign(form, {
|
||||||
|
goods_code: row.goods_code,
|
||||||
|
goods_name: row.goods_name,
|
||||||
|
goods_type: row.goods_type,
|
||||||
|
price: row.price / 100,
|
||||||
|
discount_price: row.discount_price / 100,
|
||||||
|
member_days: row.member_days,
|
||||||
|
upload_quota: row.upload_quota,
|
||||||
|
ai_recommend_quota: row.ai_recommend_quota,
|
||||||
|
closet_share_quota: row.closet_share_quota,
|
||||||
|
outfit_share_quota: row.outfit_share_quota,
|
||||||
|
valid_days: row.valid_days,
|
||||||
|
description: row.description,
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const price = yuanToCents(form.price)
|
||||||
|
const discountPrice = yuanToCents(form.discount_price)
|
||||||
|
if (editId.value === null) {
|
||||||
|
await createGoods({
|
||||||
|
goods_code: form.goods_code,
|
||||||
|
goods_name: form.goods_name,
|
||||||
|
goods_type: form.goods_type,
|
||||||
|
price,
|
||||||
|
discount_price: discountPrice,
|
||||||
|
member_days: form.member_days,
|
||||||
|
upload_quota: form.upload_quota,
|
||||||
|
ai_recommend_quota: form.ai_recommend_quota,
|
||||||
|
closet_share_quota: form.closet_share_quota,
|
||||||
|
outfit_share_quota: form.outfit_share_quota,
|
||||||
|
valid_days: form.valid_days,
|
||||||
|
description: form.description || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('商品创建成功')
|
||||||
|
} else {
|
||||||
|
await updateGoods(editId.value, {
|
||||||
|
goods_name: form.goods_name,
|
||||||
|
price,
|
||||||
|
discount_price: discountPrice,
|
||||||
|
member_days: form.member_days,
|
||||||
|
upload_quota: form.upload_quota,
|
||||||
|
ai_recommend_quota: form.ai_recommend_quota,
|
||||||
|
closet_share_quota: form.closet_share_quota,
|
||||||
|
outfit_share_quota: form.outfit_share_quota,
|
||||||
|
valid_days: form.valid_days,
|
||||||
|
description: form.description || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('商品更新成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 上下架 / 删除 ---------------- */
|
||||||
|
async function toggleStatus(row: GoodsItem) {
|
||||||
|
const nextStatus = row.status === 1 ? 2 : 1
|
||||||
|
const action = nextStatus === 2 ? '下架' : '上架'
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`确认将商品「${row.goods_name}」${action}吗?`,
|
||||||
|
`${action}确认`,
|
||||||
|
{ type: 'warning' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await updateGoodsStatus(row.id, { status: nextStatus })
|
||||||
|
ElMessage.success(`${action}成功`)
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: GoodsItem) {
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`删除后商品将不可见(软删除),确认删除「${row.goods_name}」吗?`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await deleteGoods(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-select v-model="query.goods_type" placeholder="商品类型" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(GOODS_TYPE)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="query.status" placeholder="状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(GOODS_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="商品名称 / 编码"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreate">新增商品</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="goods_code" label="商品编码" width="130" />
|
||||||
|
<el-table-column prop="goods_name" label="商品名称" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类型" width="110">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">
|
||||||
|
<el-tag :type="enumTag(GOODS_TYPE, row.goods_type)" size="small">
|
||||||
|
{{ enumLabel(GOODS_TYPE, row.goods_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="原价" width="100" align="right">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">
|
||||||
|
<span class="price-original">¥{{ formatMoney(row.price) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="售价" width="100" align="right">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(row.discount_price) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="member_days" label="会员天数" width="90" align="right" />
|
||||||
|
<el-table-column label="上传额度" width="90" align="right">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">{{ row.upload_quota }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="AI 额度" width="90" align="right">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">{{ row.ai_recommend_quota }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="虚拟支付" width="110">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">
|
||||||
|
<el-tag v-if="row.product_id" :type="enumTag(PRODUCT_STATUS, row.product_status)" size="small">
|
||||||
|
{{ enumLabel(PRODUCT_STATUS, row.product_status) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">
|
||||||
|
<el-tag :type="enumTag(GOODS_STATUS, row.status)" size="small">
|
||||||
|
{{ enumLabel(GOODS_STATUS, row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="150">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">{{ formatDate(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="170" fixed="right">
|
||||||
|
<template #default="{ row }: { row: GoodsItem }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button v-if="row.status === 1" link type="warning" size="small" @click="toggleStatus(row)">
|
||||||
|
下架
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else link type="success" size="small" @click="toggleStatus(row)">上架</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无商品数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editId === null ? '新增商品' : '编辑商品'"
|
||||||
|
width="640px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="商品编码" prop="goods_code">
|
||||||
|
<el-input v-model="form.goods_code" :disabled="editId !== null" placeholder="唯一编码" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="商品名称" prop="goods_name">
|
||||||
|
<el-input v-model="form.goods_name" placeholder="商品名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="商品类型" prop="goods_type">
|
||||||
|
<el-select v-model="form.goods_type" style="width: 100%" :disabled="editId !== null">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(GOODS_TYPE)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="会员天数">
|
||||||
|
<el-input-number v-model="form.member_days" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="原价(元)" prop="price">
|
||||||
|
<el-input-number v-model="form.price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="售价(元)" prop="discount_price">
|
||||||
|
<el-input-number v-model="form.discount_price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="上传额度">
|
||||||
|
<el-input-number v-model="form.upload_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="AI 推荐额度">
|
||||||
|
<el-input-number v-model="form.ai_recommend_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="衣柜分享额度">
|
||||||
|
<el-input-number v-model="form.closet_share_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="搭配分享额度">
|
||||||
|
<el-input-number v-model="form.outfit_share_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="有效期天数">
|
||||||
|
<el-input-number v-model="form.valid_days" :min="0" :max="3650" controls-position="right" style="width: 100%" />
|
||||||
|
<div class="form-tip">加量包有效期;会员包填 0 表示跟随 VIP</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="商品描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="submitForm">
|
||||||
|
{{ editId === null ? '创建' : '保存' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-original {
|
||||||
|
color: #9aa3c0;
|
||||||
|
text-decoration: line-through;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Lock, User } from '@element-plus/icons-vue'
|
||||||
|
import { login } from '@/api/auth'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import type { AdminLoginRequest } from '@/types/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive<AdminLoginRequest>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules<AdminLoginRequest> = {
|
||||||
|
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await login({ username: form.username.trim(), password: form.password })
|
||||||
|
authStore.setSession(res)
|
||||||
|
ElMessage.success(`欢迎回来,${res.admin_info.nickname || res.admin_info.username}`)
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||||
|
router.replace(redirect)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层统一处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-bg-blob blob-a"></div>
|
||||||
|
<div class="login-bg-blob blob-b"></div>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="login-logo">
|
||||||
|
<el-icon :size="30"><User /></el-icon>
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">纸条衣橱管理后台</h1>
|
||||||
|
<p class="login-subtitle">ZhiTiao Closet Admin Console</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
size="large"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input v-model="form.username" placeholder="登录账号" :prefix-icon="User" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
class="login-submit"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #1e2438 0%, #2c3560 55%, #4f6ef7 130%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(90px);
|
||||||
|
opacity: 0.45;
|
||||||
|
animation: blob-float 12s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-a {
|
||||||
|
width: 420px;
|
||||||
|
height: 420px;
|
||||||
|
background: #4f6ef7;
|
||||||
|
top: -120px;
|
||||||
|
left: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-b {
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
bottom: -120px;
|
||||||
|
right: -80px;
|
||||||
|
animation-delay: -6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blob-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(40px, -30px) scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 400px;
|
||||||
|
padding: 44px 40px 36px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 24px 64px rgba(10, 16, 40, 0.35);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||||
|
box-shadow: 0 8px 20px rgba(79, 110, 247, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2437;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { Bell, Promotion } from '@element-plus/icons-vue'
|
||||||
|
import { broadcastNotification, sendNotification } from '@/api/notification'
|
||||||
|
|
||||||
|
/* ---------------- 单用户通知 ---------------- */
|
||||||
|
const singleFormRef = ref<FormInstance>()
|
||||||
|
const singleLoading = ref(false)
|
||||||
|
|
||||||
|
const singleForm = reactive({
|
||||||
|
user_id: undefined as number | undefined,
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const singleRules: FormRules = {
|
||||||
|
user_id: [{ required: true, message: '请输入用户 ID', trigger: 'blur' }],
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入通知标题', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '标题不超过 64 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{ required: true, message: '请输入通知内容', trigger: 'blur' },
|
||||||
|
{ max: 500, message: '内容不超过 500 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSingle() {
|
||||||
|
const valid = await singleFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !singleForm.user_id) return
|
||||||
|
singleLoading.value = true
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
user_id: singleForm.user_id,
|
||||||
|
title: singleForm.title,
|
||||||
|
content: singleForm.content,
|
||||||
|
})
|
||||||
|
ElMessage.success('通知已发送')
|
||||||
|
singleForm.title = ''
|
||||||
|
singleForm.content = ''
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
singleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 全量广播 ---------------- */
|
||||||
|
const broadcastFormRef = ref<FormInstance>()
|
||||||
|
const broadcastLoading = ref(false)
|
||||||
|
|
||||||
|
const broadcastForm = reactive({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const broadcastRules: FormRules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入通知标题', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '标题不超过 64 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{ required: true, message: '请输入通知内容', trigger: 'blur' },
|
||||||
|
{ max: 500, message: '内容不超过 500 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBroadcast() {
|
||||||
|
const valid = await broadcastFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
'全量广播将向所有用户推送该通知(分批写入),确认发送吗?',
|
||||||
|
'广播确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认广播' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
broadcastLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await broadcastNotification({
|
||||||
|
title: broadcastForm.title,
|
||||||
|
content: broadcastForm.content,
|
||||||
|
})
|
||||||
|
ElMessage.success(`广播成功,已推送 ${res.sent_count} 位用户`)
|
||||||
|
broadcastForm.title = ''
|
||||||
|
broadcastForm.content = ''
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
broadcastLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notify-page">
|
||||||
|
<div class="notify-grid">
|
||||||
|
<!-- 单用户通知 -->
|
||||||
|
<div class="zt-card notify-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-head-icon icon-single"><el-icon :size="20"><Bell /></el-icon></div>
|
||||||
|
<div>
|
||||||
|
<div class="zt-card-title" style="margin-bottom: 2px">单用户通知</div>
|
||||||
|
<div class="card-sub">向指定用户发送系统通知,实时送达小程序</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form ref="singleFormRef" :model="singleForm" :rules="singleRules" label-width="80px">
|
||||||
|
<el-form-item label="用户 ID" prop="user_id">
|
||||||
|
<el-input-number
|
||||||
|
v-model="singleForm.user_id"
|
||||||
|
:min="1"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请输入用户 ID"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input v-model="singleForm.title" maxlength="64" show-word-limit placeholder="通知标题(≤64 字)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内容" prop="content">
|
||||||
|
<el-input
|
||||||
|
v-model="singleForm.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="通知内容(≤500 字)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Bell" :loading="singleLoading" @click="submitSingle">
|
||||||
|
发送通知
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全量广播 -->
|
||||||
|
<div class="zt-card notify-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-head-icon icon-broadcast"><el-icon :size="20"><Promotion /></el-icon></div>
|
||||||
|
<div>
|
||||||
|
<div class="zt-card-title" style="margin-bottom: 2px">全量广播</div>
|
||||||
|
<div class="card-sub">向全部用户推送,分批写入并同步未读计数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form ref="broadcastFormRef" :model="broadcastForm" :rules="broadcastRules" label-width="80px">
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input v-model="broadcastForm.title" maxlength="64" show-word-limit placeholder="通知标题(≤64 字)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内容" prop="content">
|
||||||
|
<el-input
|
||||||
|
v-model="broadcastForm.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="通知内容(≤500 字)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="danger" :icon="Promotion" :loading="broadcastLoading" @click="submitBroadcast">
|
||||||
|
全量广播
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notify-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.notify-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-single {
|
||||||
|
background: linear-gradient(135deg, #4f6ef7, #6a5cf0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-broadcast {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { getOrderDetail, getOrderList } from '@/api/order'
|
||||||
|
import type { OrderItem } from '@/types/order'
|
||||||
|
import { ORDER_STATUS, PAY_CHANNEL, XPAY_ENV, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||||
|
import { formatDate, formatMoney, formatText } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<OrderItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
order_no: '',
|
||||||
|
user_id: undefined as number | undefined,
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getOrderList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
order_no: query.order_no || undefined,
|
||||||
|
user_id: query.user_id || undefined,
|
||||||
|
status: query.status,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.order_no = ''
|
||||||
|
query.user_id = undefined
|
||||||
|
query.status = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 详情抽屉 ---------------- */
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detail = ref<OrderItem | null>(null)
|
||||||
|
|
||||||
|
async function openDetail(row: OrderItem) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getOrderDetail(row.order_no)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-input
|
||||||
|
v-model="query.order_no"
|
||||||
|
placeholder="订单号(精确)"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model.number="query.user_id"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select v-model="query.status" placeholder="订单状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(ORDER_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||||
|
<el-table-column prop="goods_name" label="商品" min-width="130" show-overflow-tooltip />
|
||||||
|
<el-table-column label="实付金额" width="110" align="right">
|
||||||
|
<template #default="{ row }: { row: OrderItem }">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(row.pay_amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }: { row: OrderItem }">
|
||||||
|
<el-tag :type="enumTag(ORDER_STATUS, row.order_status)" size="small">
|
||||||
|
{{ enumLabel(ORDER_STATUS, row.order_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="支付渠道" width="100">
|
||||||
|
<template #default="{ row }: { row: OrderItem }">
|
||||||
|
{{ enumLabel(PAY_CHANNEL, row.pay_channel) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="支付时间" width="150">
|
||||||
|
<template #default="{ row }: { row: OrderItem }">{{ formatDate(row.pay_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="150">
|
||||||
|
<template #default="{ row }: { row: OrderItem }">{{ formatDate(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
|
<template #default="{ row }: { row: OrderItem }">
|
||||||
|
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无订单数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 订单详情抽屉 -->
|
||||||
|
<el-drawer v-model="detailVisible" title="订单详情" size="480px">
|
||||||
|
<div v-loading="detailLoading" class="detail-body">
|
||||||
|
<el-descriptions v-if="detail" :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="订单号">{{ detail.order_no }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品名称">{{ formatText(detail.goods_name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="enumTag(ORDER_STATUS, detail.order_status)" size="small">
|
||||||
|
{{ enumLabel(ORDER_STATUS, detail.order_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付渠道">
|
||||||
|
{{ enumLabel(PAY_CHANNEL, detail.pay_channel) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="微信交易号">{{ formatText(detail.transaction_id) }}</el-descriptions-item>
|
||||||
|
<template v-if="detail.xpay_product_id">
|
||||||
|
<el-descriptions-item label="虚拟支付道具 ID">{{ formatText(detail.xpay_product_id) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="虚拟支付平台">{{ formatText(detail.xpay_platform) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="虚拟支付环境">{{ enumLabel(XPAY_ENV, detail.xpay_env) }}</el-descriptions-item>
|
||||||
|
</template>
|
||||||
|
<el-descriptions-item label="单价">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(detail.unit_price) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单总额">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(detail.total_amount) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="实付金额">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(detail.pay_amount) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="退款金额">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(detail.refund_amount) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="退款时间">{{ formatDate(detail.refund_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="会员天数">{{ detail.member_days }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="上传额度">{{ detail.upload_quota }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="AI 推荐额度">{{ detail.ai_recommend_quota }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="衣柜分享额度">{{ detail.closet_share_quota }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="搭配分享额度">{{ detail.outfit_share_quota }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="权益到期">{{ formatDate(detail.expire_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付时间">{{ formatDate(detail.pay_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注">{{ formatText(detail.remark) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { approveRefund, getRefundList, rejectRefund } from '@/api/order'
|
||||||
|
import type { RefundItem } from '@/types/order'
|
||||||
|
import { REFUND_STATUS, enumLabel, enumTag } from '@/utils/constants'
|
||||||
|
import { formatDate, formatMoney, formatText } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<RefundItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态筛选:undefined = 全部(不传),0 = 待处理(显式传 0)
|
||||||
|
* 后端用指针区分「不传」与「传 0」,前端用 -1 作为「全部」的占位值
|
||||||
|
*/
|
||||||
|
const query = reactive({
|
||||||
|
status: -1 as number,
|
||||||
|
order_no: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: -1, label: '全部状态' },
|
||||||
|
{ value: 0, label: '待处理' },
|
||||||
|
{ value: 1, label: '退款中' },
|
||||||
|
{ value: 2, label: '退款成功' },
|
||||||
|
{ value: 3, label: '退款失败' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRefundList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
status: query.status === -1 ? undefined : query.status,
|
||||||
|
order_no: query.order_no || undefined,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.status = -1
|
||||||
|
query.order_no = ''
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 审核(通过 / 驳回) ---------------- */
|
||||||
|
const auditVisible = ref(false)
|
||||||
|
const auditLoading = ref(false)
|
||||||
|
const auditMode = ref<'approve' | 'reject'>('approve')
|
||||||
|
const auditTarget = ref<RefundItem | null>(null)
|
||||||
|
const auditFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const auditForm = reactive({
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const auditRules: FormRules = {
|
||||||
|
reason: [
|
||||||
|
{ required: true, message: '请填写审核备注', trigger: 'blur' },
|
||||||
|
{ max: 200, message: '备注不超过 200 字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAudit(mode: 'approve' | 'reject', row: RefundItem) {
|
||||||
|
auditMode.value = mode
|
||||||
|
auditTarget.value = row
|
||||||
|
auditForm.reason = ''
|
||||||
|
auditVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
const valid = await auditFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !auditTarget.value) return
|
||||||
|
auditLoading.value = true
|
||||||
|
try {
|
||||||
|
if (auditMode.value === 'approve') {
|
||||||
|
await approveRefund(auditTarget.value.refund_no, { reason: auditForm.reason })
|
||||||
|
ElMessage.success('已通过,退款单进入「退款中」状态')
|
||||||
|
} else {
|
||||||
|
await rejectRefund(auditTarget.value.refund_no, { reason: auditForm.reason })
|
||||||
|
ElMessage.success('已驳回')
|
||||||
|
}
|
||||||
|
auditVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReject(row: RefundItem) {
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`驳回后该退款单将标记为「退款失败」,确认驳回订单 ${row.order_no} 的退款吗?`,
|
||||||
|
'驳回确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '继续驳回' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
openAudit('reject', row)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-select v-model="query.status" placeholder="退款状态">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in statusOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="query.order_no"
|
||||||
|
placeholder="关联订单号"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="refund_no" label="退款单号" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="order_no" label="关联订单号" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||||
|
<el-table-column label="退款金额" width="110" align="right">
|
||||||
|
<template #default="{ row }: { row: RefundItem }">
|
||||||
|
<span class="zt-money">¥{{ formatMoney(row.refund_amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }: { row: RefundItem }">
|
||||||
|
<el-tag :type="enumTag(REFUND_STATUS, row.status)" size="small">
|
||||||
|
{{ enumLabel(REFUND_STATUS, row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申请原因" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }: { row: RefundItem }">{{ formatText(row.refund_reason) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申请时间" width="150">
|
||||||
|
<template #default="{ row }: { row: RefundItem }">{{ formatDate(row.apply_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="失败原因" min-width="140" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }: { row: RefundItem }">{{ formatText(row.fail_reason) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }: { row: RefundItem }">
|
||||||
|
<template v-if="row.status === 0">
|
||||||
|
<el-button link type="success" size="small" @click="openAudit('approve', row)">通过</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="confirmReject(row)">驳回</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="no-action">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无退款单数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 审核对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="auditVisible"
|
||||||
|
:title="auditMode === 'approve' ? '退款审核通过' : '退款审核驳回'"
|
||||||
|
width="460px"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
v-if="auditTarget"
|
||||||
|
:title="`退款单 ${auditTarget.refund_no} · 金额 ¥${formatMoney(auditTarget.refund_amount)}`"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
class="audit-alert"
|
||||||
|
/>
|
||||||
|
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="88px">
|
||||||
|
<el-form-item :label="auditMode === 'approve' ? '审核备注' : '驳回原因'" prop="reason">
|
||||||
|
<el-input
|
||||||
|
v-model="auditForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="auditMode === 'approve' ? '如:已核实,同意退款(必填)' : '请填写驳回原因(必填)'"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="auditVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
:type="auditMode === 'approve' ? 'success' : 'danger'"
|
||||||
|
:loading="auditLoading"
|
||||||
|
@click="submitAudit"
|
||||||
|
>
|
||||||
|
{{ auditMode === 'approve' ? '确认通过' : '确认驳回' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audit-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-action {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { getClothesDetail, getClothesList } from '@/api/content'
|
||||||
|
import type { ClothesItem } from '@/types/content'
|
||||||
|
import { IMAGE_PROCESS_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||||
|
import { formatDate, formatText } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<ClothesItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
user_id: undefined as number | undefined,
|
||||||
|
image_process_status: undefined as number | undefined,
|
||||||
|
category_main: '',
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getClothesList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
user_id: query.user_id || undefined,
|
||||||
|
image_process_status: query.image_process_status,
|
||||||
|
category_main: query.category_main || undefined,
|
||||||
|
keyword: query.keyword || undefined,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.user_id = undefined
|
||||||
|
query.image_process_status = undefined
|
||||||
|
query.category_main = ''
|
||||||
|
query.keyword = ''
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 详情抽屉 ---------------- */
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detail = ref<ClothesItem | null>(null)
|
||||||
|
|
||||||
|
async function openDetail(row: ClothesItem) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getClothesDetail(row.clothes_uuid)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-input
|
||||||
|
v-model.number="query.user_id"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select v-model="query.image_process_status" placeholder="图片处理状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(IMAGE_PROCESS_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="query.category_main"
|
||||||
|
placeholder="主类目"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="衣物名称 / 描述"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column label="衣物图片" width="80">
|
||||||
|
<template #default="{ row }: { row: ClothesItem }">
|
||||||
|
<el-image
|
||||||
|
v-if="row.processed_image"
|
||||||
|
:src="row.processed_image"
|
||||||
|
:preview-src-list="[row.processed_image]"
|
||||||
|
preview-teleported
|
||||||
|
fit="cover"
|
||||||
|
class="clothes-thumb"
|
||||||
|
/>
|
||||||
|
<div v-else class="thumb-placeholder">
|
||||||
|
<el-icon><Picture /></el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="130" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||||
|
<el-table-column prop="category_main" label="主类目" width="100" />
|
||||||
|
<el-table-column prop="category_sub" label="子类目" width="100" />
|
||||||
|
<el-table-column label="处理状态" width="100">
|
||||||
|
<template #default="{ row }: { row: ClothesItem }">
|
||||||
|
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, row.image_process_status)" size="small">
|
||||||
|
{{ enumLabel(IMAGE_PROCESS_STATUS, row.image_process_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="150">
|
||||||
|
<template #default="{ row }: { row: ClothesItem }">{{ formatDate(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
|
<template #default="{ row }: { row: ClothesItem }">
|
||||||
|
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无衣物数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 衣物详情抽屉 -->
|
||||||
|
<el-drawer v-model="detailVisible" title="衣物详情" size="520px">
|
||||||
|
<div v-loading="detailLoading" class="detail-body">
|
||||||
|
<template v-if="detail">
|
||||||
|
<div class="detail-image">
|
||||||
|
<el-image
|
||||||
|
v-if="detail.processed_image"
|
||||||
|
:src="detail.processed_image"
|
||||||
|
:preview-src-list="[detail.processed_image]"
|
||||||
|
preview-teleported
|
||||||
|
fit="contain"
|
||||||
|
class="detail-img"
|
||||||
|
/>
|
||||||
|
<div v-else class="thumb-placeholder large">
|
||||||
|
<el-icon :size="32"><Picture /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="名称" :span="2">{{ formatText(detail.name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="主类目">{{ formatText(detail.category_main) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="子类目">{{ formatText(detail.category_sub) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="风格">{{ formatText(detail.style) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="主色">{{ formatText(detail.color_main) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="副色">{{ formatText(detail.color_sub) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="厚薄">{{ formatText(detail.thickness) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="版型">{{ formatText(detail.version) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="材质">{{ formatText(detail.material) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="图案">{{ formatText(detail.pattern) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="袖型">{{ formatText(detail.sleeve_type) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="衣型">{{ formatText(detail.clothes_type) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="领型">{{ formatText(detail.neckline_type) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="正式度">{{ formatText(detail.formality) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="场合">{{ formatText(detail.occasions) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="视觉冲击">{{ formatText(detail.visual_impact) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="图片大小">{{ detail.image_size }} KB</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="处理状态">
|
||||||
|
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, detail.image_process_status)" size="small">
|
||||||
|
{{ enumLabel(IMAGE_PROCESS_STATUS, detail.image_process_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="失败原因">{{ formatText(detail.fail_reason) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">{{ formatText(detail.remark) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间" :span="2">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clothes-thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f4f6fb;
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder.large {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-image {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f4f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { auditOutfit, banOutfit, getOutfitDetail, getOutfitList, unbanOutfit } from '@/api/content'
|
||||||
|
import type { OutfitDetailResponse, OutfitListItem } from '@/types/content'
|
||||||
|
import {
|
||||||
|
IMAGE_PROCESS_STATUS,
|
||||||
|
OUTFIT_AUDIT_STATUS,
|
||||||
|
OUTFIT_PUBLIC,
|
||||||
|
OUTFIT_SOURCE,
|
||||||
|
OUTFIT_STATUS,
|
||||||
|
enumLabel,
|
||||||
|
enumOptions,
|
||||||
|
enumTag,
|
||||||
|
} from '@/utils/constants'
|
||||||
|
import { formatDate, formatText } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<OutfitListItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
user_id: undefined as number | undefined,
|
||||||
|
is_public: undefined as number | undefined,
|
||||||
|
audit_status: undefined as number | undefined,
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getOutfitList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
user_id: query.user_id || undefined,
|
||||||
|
is_public: query.is_public,
|
||||||
|
audit_status: query.audit_status,
|
||||||
|
status: query.status,
|
||||||
|
keyword: query.keyword || undefined,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.user_id = undefined
|
||||||
|
query.is_public = undefined
|
||||||
|
query.audit_status = undefined
|
||||||
|
query.status = undefined
|
||||||
|
query.keyword = ''
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 详情抽屉 ---------------- */
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detail = ref<OutfitDetailResponse | null>(null)
|
||||||
|
|
||||||
|
async function openDetail(row: OutfitListItem) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getOutfitDetail(row.outfit_uuid)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 审核 ---------------- */
|
||||||
|
const auditVisible = ref(false)
|
||||||
|
const auditLoading = ref(false)
|
||||||
|
const auditTarget = ref<OutfitListItem | null>(null)
|
||||||
|
const auditFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const auditForm = reactive({
|
||||||
|
result: 1,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const auditRules: FormRules = {
|
||||||
|
result: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
|
||||||
|
reason: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (auditForm.result === 2 && !value.trim()) {
|
||||||
|
callback(new Error('驳回时必须填写原因'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAudit(row: OutfitListItem) {
|
||||||
|
auditTarget.value = row
|
||||||
|
auditForm.result = 1
|
||||||
|
auditForm.reason = ''
|
||||||
|
auditVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
const valid = await auditFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !auditTarget.value) return
|
||||||
|
auditLoading.value = true
|
||||||
|
try {
|
||||||
|
await auditOutfit(auditTarget.value.outfit_uuid, {
|
||||||
|
result: auditForm.result,
|
||||||
|
reason: auditForm.result === 2 ? auditForm.reason : undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success(auditForm.result === 1 ? '审核通过' : '已驳回')
|
||||||
|
auditVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 封禁 / 解封 ---------------- */
|
||||||
|
async function handleBan(row: OutfitListItem) {
|
||||||
|
const result = (await ElMessageBox.prompt('封禁原因(选填)', `封禁穿搭「${row.outfit_name}」`, {
|
||||||
|
confirmButtonText: '确认封禁',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '选填,最多 200 字',
|
||||||
|
inputValidator: (val: string) => (val.length <= 200 ? true : '原因不能超过 200 字'),
|
||||||
|
}).catch(() => null)) as { value: string } | null
|
||||||
|
if (!result) return
|
||||||
|
const reason = result.value
|
||||||
|
await banOutfit(row.outfit_uuid, { reason: reason || undefined })
|
||||||
|
ElMessage.success('封禁成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnban(row: OutfitListItem) {
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`确认解封穿搭「${row.outfit_name}」吗?`,
|
||||||
|
'解封确认',
|
||||||
|
{ type: 'warning' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await unbanOutfit(row.outfit_uuid)
|
||||||
|
ElMessage.success('解封成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-input
|
||||||
|
v-model.number="query.user_id"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select v-model="query.is_public" placeholder="公开状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(OUTFIT_PUBLIC)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="query.audit_status" placeholder="审核状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(OUTFIT_AUDIT_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="query.status" placeholder="穿搭状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(OUTFIT_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="穿搭名称 / 描述"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="outfit_name" label="穿搭名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||||
|
<el-table-column label="来源" width="100">
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">
|
||||||
|
<el-tag :type="enumTag(OUTFIT_SOURCE, row.source)" size="small">
|
||||||
|
{{ enumLabel(OUTFIT_SOURCE, row.source) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="公开" width="80">
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">
|
||||||
|
{{ enumLabel(OUTFIT_PUBLIC, row.is_public) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="审核状态" width="100">
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">
|
||||||
|
<el-tag :type="enumTag(OUTFIT_AUDIT_STATUS, row.audit_status)" size="small">
|
||||||
|
{{ enumLabel(OUTFIT_AUDIT_STATUS, row.audit_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">
|
||||||
|
<el-tag :type="enumTag(OUTFIT_STATUS, row.status)" size="small">
|
||||||
|
{{ enumLabel(OUTFIT_STATUS, row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="场景" min-width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">{{ formatText(row.scene) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="150">
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">{{ formatDate(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }: { row: OutfitListItem }">
|
||||||
|
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||||
|
<el-button v-if="row.audit_status === 1" link type="warning" size="small" @click="openAudit(row)">
|
||||||
|
审核
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleBan(row)">封禁</el-button>
|
||||||
|
<el-button v-if="row.status === 2" link type="success" size="small" @click="handleUnban(row)">解封</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无穿搭数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 穿搭详情抽屉 -->
|
||||||
|
<el-drawer v-model="detailVisible" title="穿搭详情" size="520px">
|
||||||
|
<div v-loading="detailLoading" class="detail-body">
|
||||||
|
<template v-if="detail">
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="名称">{{ formatText(detail.outfit_name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="描述">{{ formatText(detail.outfit_desc) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="场景">{{ formatText(detail.scene) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="来源">{{ enumLabel(OUTFIT_SOURCE, detail.source) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="公开状态">
|
||||||
|
{{ enumLabel(OUTFIT_PUBLIC, detail.is_public) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="审核状态">
|
||||||
|
<el-tag :type="enumTag(OUTFIT_AUDIT_STATUS, detail.audit_status)" size="small">
|
||||||
|
{{ enumLabel(OUTFIT_AUDIT_STATUS, detail.audit_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="enumTag(OUTFIT_STATUS, detail.status)" size="small">
|
||||||
|
{{ enumLabel(OUTFIT_STATUS, detail.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<h4 class="section-title">衣物明细({{ detail.items.length }})</h4>
|
||||||
|
<el-table :data="detail.items" size="small" border>
|
||||||
|
<el-table-column prop="name" label="衣物名称" min-width="110" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="category_main" label="主类目" width="90" />
|
||||||
|
<el-table-column prop="category_sub" label="子类目" width="90" />
|
||||||
|
<el-table-column label="图片状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, row.image_process_status)" size="small">
|
||||||
|
{{ enumLabel(IMAGE_PROCESS_STATUS, row.image_process_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 审核对话框 -->
|
||||||
|
<el-dialog v-model="auditVisible" :title="`穿搭审核 · ${auditTarget?.outfit_name ?? ''}`" width="460px">
|
||||||
|
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="88px">
|
||||||
|
<el-form-item label="审核结果" prop="result">
|
||||||
|
<el-radio-group v-model="auditForm.result">
|
||||||
|
<el-radio :value="1">通过</el-radio>
|
||||||
|
<el-radio :value="2">驳回</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="auditForm.result === 2" label="驳回原因" prop="reason">
|
||||||
|
<el-input
|
||||||
|
v-model="auditForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="驳回时必填原因"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="auditVisible = false">取消</el-button>
|
||||||
|
<el-button :type="auditForm.result === 1 ? 'success' : 'danger'" :loading="auditLoading" @click="submitAudit">
|
||||||
|
{{ auditForm.result === 1 ? '确认通过' : '确认驳回' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2437;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import { banClosetShare, getClosetShareList, unbanClosetShare } from '@/api/content'
|
||||||
|
import type { ClosetShareItem } from '@/types/content'
|
||||||
|
import { SHARE_STATUS, SHARE_TYPE, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||||
|
import { formatDate } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<ClosetShareItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
user_id: undefined as number | undefined,
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getClosetShareList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
user_id: query.user_id || undefined,
|
||||||
|
status: query.status,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.user_id = undefined
|
||||||
|
query.status = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 封禁 / 解封 ---------------- */
|
||||||
|
async function handleBan(row: ClosetShareItem) {
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`封禁后该分享将对外不可见,确认封禁分享 ${row.share_uuid} 吗?`,
|
||||||
|
'封禁确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认封禁' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await banClosetShare(row.share_uuid)
|
||||||
|
ElMessage.success('封禁成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnban(row: ClosetShareItem) {
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`确认解封分享 ${row.share_uuid} 吗?`,
|
||||||
|
'解封确认',
|
||||||
|
{ type: 'warning' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await unbanClosetShare(row.share_uuid)
|
||||||
|
ElMessage.success('解封成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-input
|
||||||
|
v-model.number="query.user_id"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select v-model="query.status" placeholder="分享状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(SHARE_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="share_uuid" label="分享 UUID" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||||
|
<el-table-column label="分享类型" width="110">
|
||||||
|
<template #default="{ row }: { row: ClosetShareItem }">
|
||||||
|
{{ enumLabel(SHARE_TYPE, row.share_type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }: { row: ClosetShareItem }">
|
||||||
|
<el-tag :type="enumTag(SHARE_STATUS, row.status)" size="small">
|
||||||
|
{{ enumLabel(SHARE_STATUS, row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过期时间" width="150">
|
||||||
|
<template #default="{ row }: { row: ClosetShareItem }">{{ formatDate(row.expire_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="150">
|
||||||
|
<template #default="{ row }: { row: ClosetShareItem }">{{ formatDate(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }: { row: ClosetShareItem }">
|
||||||
|
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleBan(row)">封禁</el-button>
|
||||||
|
<el-button v-if="row.status === 4" link type="success" size="small" @click="handleUnban(row)">解封</el-button>
|
||||||
|
<span v-if="row.status === 2 || row.status === 3" class="no-action">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无分享数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.no-action {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||||
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
import {
|
||||||
|
adjustUserQuota,
|
||||||
|
adjustUserVip,
|
||||||
|
banUser,
|
||||||
|
getUserDetail,
|
||||||
|
getUserList,
|
||||||
|
revokeGrant,
|
||||||
|
unbanUser,
|
||||||
|
} from '@/api/user'
|
||||||
|
import type {
|
||||||
|
UserDetailResponse,
|
||||||
|
UserGrantItem,
|
||||||
|
UserListItem,
|
||||||
|
} from '@/types/user'
|
||||||
|
import {
|
||||||
|
BAN_TYPE,
|
||||||
|
GRANT_SOURCE,
|
||||||
|
QUOTA_TYPE,
|
||||||
|
USER_SEX,
|
||||||
|
USER_STATUS,
|
||||||
|
VIP_TYPE,
|
||||||
|
enumLabel,
|
||||||
|
enumOptions,
|
||||||
|
enumTag,
|
||||||
|
} from '@/utils/constants'
|
||||||
|
import { formatDate, formatText } from '@/utils/format'
|
||||||
|
|
||||||
|
/* ---------------- 列表与筛选 ---------------- */
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<UserListItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
keyword: '',
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getUserList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
keyword: query.keyword || undefined,
|
||||||
|
status: query.status,
|
||||||
|
})
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.keyword = ''
|
||||||
|
query.status = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
|
||||||
|
/* ---------------- 详情抽屉 ---------------- */
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detail = ref<UserDetailResponse | null>(null)
|
||||||
|
|
||||||
|
async function openDetail(row: UserListItem) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getUserDetail(row.user_uuid)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 封禁 ---------------- */
|
||||||
|
const banVisible = ref(false)
|
||||||
|
const banLoading = ref(false)
|
||||||
|
const banTarget = ref<UserListItem | null>(null)
|
||||||
|
const banFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const banForm = reactive({
|
||||||
|
ban_type: 2,
|
||||||
|
ban_days: undefined as number | undefined,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const banRules: FormRules = {
|
||||||
|
ban_type: [{ required: true, message: '请选择封禁类型', trigger: 'change' }],
|
||||||
|
ban_days: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value: number | undefined, callback) => {
|
||||||
|
if (banForm.ban_type === 1 && (!value || value < 1 || value > 3650)) {
|
||||||
|
callback(new Error('临时封禁需填写 1~3650 天'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reason: [{ required: true, message: '请填写封禁原因', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBan(row: UserListItem) {
|
||||||
|
banTarget.value = row
|
||||||
|
banForm.ban_type = 2
|
||||||
|
banForm.ban_days = 7
|
||||||
|
banForm.reason = ''
|
||||||
|
banVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBan() {
|
||||||
|
const valid = await banFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !banTarget.value) return
|
||||||
|
banLoading.value = true
|
||||||
|
try {
|
||||||
|
await banUser(banTarget.value.user_uuid, {
|
||||||
|
ban_type: banForm.ban_type,
|
||||||
|
ban_days: banForm.ban_type === 1 ? banForm.ban_days : undefined,
|
||||||
|
reason: banForm.reason,
|
||||||
|
})
|
||||||
|
ElMessage.success('封禁成功')
|
||||||
|
banVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
banLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 解封 ---------------- */
|
||||||
|
async function handleUnban(row: UserListItem) {
|
||||||
|
const result = (await ElMessageBox.prompt('解封原因(选填)', '解封用户', {
|
||||||
|
confirmButtonText: '确认解封',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '选填,最多 200 字',
|
||||||
|
inputValidator: (val: string) => (val.length <= 200 ? true : '原因不能超过 200 字'),
|
||||||
|
}).catch(() => null)) as { value: string } | null
|
||||||
|
if (!result) return
|
||||||
|
const reason = result.value
|
||||||
|
if (reason === null) return
|
||||||
|
await unbanUser(row.user_uuid, { reason: reason || undefined })
|
||||||
|
ElMessage.success('解封成功')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 额度调整 ---------------- */
|
||||||
|
const quotaVisible = ref(false)
|
||||||
|
const quotaLoading = ref(false)
|
||||||
|
const quotaTarget = ref<UserListItem | null>(null)
|
||||||
|
const quotaFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const quotaForm = reactive({
|
||||||
|
quota_type: 1,
|
||||||
|
amount: 1,
|
||||||
|
expire_days: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const quotaRules: FormRules = {
|
||||||
|
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }],
|
||||||
|
amount: [
|
||||||
|
{ required: true, message: '请输入发放数量', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value: number, callback) => {
|
||||||
|
if (value < 1 || value > 100000000) callback(new Error('发放数量范围 1~100000000'))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expire_days: [
|
||||||
|
{ required: true, message: '请输入有效期天数', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value: number, callback) => {
|
||||||
|
if (value < 1 || value > 3650) callback(new Error('有效期天数范围 1~3650'))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQuota(row: UserListItem) {
|
||||||
|
quotaTarget.value = row
|
||||||
|
quotaForm.quota_type = 1
|
||||||
|
quotaForm.amount = 1
|
||||||
|
quotaForm.expire_days = 30
|
||||||
|
quotaVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitQuota() {
|
||||||
|
const valid = await quotaFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !quotaTarget.value) return
|
||||||
|
quotaLoading.value = true
|
||||||
|
try {
|
||||||
|
await adjustUserQuota(quotaTarget.value.user_uuid, {
|
||||||
|
quota_type: quotaForm.quota_type,
|
||||||
|
amount: quotaForm.amount,
|
||||||
|
expire_days: quotaForm.expire_days,
|
||||||
|
})
|
||||||
|
ElMessage.success('额度发放成功')
|
||||||
|
quotaVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
quotaLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 额度凭证回收 ---------------- */
|
||||||
|
async function handleRevokeGrant(row: UserGrantItem) {
|
||||||
|
if (!detail.value) return
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`确认回收该凭证(类型:${enumLabel(QUOTA_TYPE, row.quota_type)},数量:${row.amount})吗?`,
|
||||||
|
'回收确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认回收', confirmButtonClass: 'el-button--danger' },
|
||||||
|
).catch(() => false)
|
||||||
|
if (!confirmed) return
|
||||||
|
await revokeGrant(detail.value.user_uuid, row.id)
|
||||||
|
ElMessage.success('回收成功')
|
||||||
|
detail.value = await getUserDetail(detail.value.user_uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- VIP 调整 ---------------- */
|
||||||
|
const vipVisible = ref(false)
|
||||||
|
const vipLoading = ref(false)
|
||||||
|
const vipTarget = ref<UserListItem | null>(null)
|
||||||
|
const vipFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const vipForm = reactive({
|
||||||
|
vip_type: 2,
|
||||||
|
vip_days: 30,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const vipRules: FormRules = {
|
||||||
|
vip_type: [{ required: true, message: '请选择会员类型', trigger: 'change' }],
|
||||||
|
vip_days: [
|
||||||
|
{ required: true, message: '请输入会员天数', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value: number, callback) => {
|
||||||
|
if (value < 0 || value > 3650) callback(new Error('天数范围 0~3650(0 表示取消会员)'))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVip(row: UserListItem) {
|
||||||
|
vipTarget.value = row
|
||||||
|
vipForm.vip_type = 2
|
||||||
|
vipForm.vip_days = 30
|
||||||
|
vipForm.reason = ''
|
||||||
|
vipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVip() {
|
||||||
|
const valid = await vipFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !vipTarget.value) return
|
||||||
|
vipLoading.value = true
|
||||||
|
try {
|
||||||
|
await adjustUserVip(vipTarget.value.user_uuid, {
|
||||||
|
vip_type: vipForm.vip_type,
|
||||||
|
vip_days: vipForm.vip_days,
|
||||||
|
reason: vipForm.reason,
|
||||||
|
})
|
||||||
|
ElMessage.success('VIP 调整成功')
|
||||||
|
vipVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由请求层处理
|
||||||
|
} finally {
|
||||||
|
vipLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="昵称 / 手机号 / OpenID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select v-model="query.status" placeholder="用户状态" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(USER_STATUS)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</SearchFormCard>
|
||||||
|
|
||||||
|
<div class="zt-card">
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column label="用户" min-width="160">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
<div class="cell-user">
|
||||||
|
<el-avatar :size="30" :src="row.avatar || undefined">
|
||||||
|
{{ (row.username || '?').slice(0, 1) }}
|
||||||
|
</el-avatar>
|
||||||
|
<span class="cell-name">{{ formatText(row.username) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="性别" width="64">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
{{ enumLabel(USER_SEX, row.sex) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="手机号" width="130">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">{{ formatText(row.phone) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
<el-tag :type="enumTag(USER_STATUS, row.status)" size="small">
|
||||||
|
{{ enumLabel(USER_STATUS, row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="会员" width="100">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
<el-tag :type="enumTag(VIP_TYPE, row.vip_type)" size="small">
|
||||||
|
{{ enumLabel(VIP_TYPE, row.vip_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="会员到期" width="150">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
{{ formatDate(row.vip_expire) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="注册时间" width="150">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
{{ formatDate(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right">
|
||||||
|
<template #default="{ row }: { row: UserListItem }">
|
||||||
|
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||||
|
<el-button v-if="row.status === 1" link type="warning" size="small" @click="openBan(row)">
|
||||||
|
封禁
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else-if="row.status === 2 || row.status === 3" link type="success" size="small" @click="handleUnban(row)">
|
||||||
|
解封
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="openQuota(row)">额度</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="openVip(row)">VIP</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无用户数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户详情抽屉 -->
|
||||||
|
<el-drawer v-model="detailVisible" title="用户详情" size="460px">
|
||||||
|
<div v-loading="detailLoading" class="detail-body">
|
||||||
|
<template v-if="detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<el-avatar :size="56" :src="detail.avatar || undefined">
|
||||||
|
{{ (detail.username || '?').slice(0, 1) }}
|
||||||
|
</el-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="detail-name">{{ formatText(detail.username) }}</div>
|
||||||
|
<div class="detail-uuid">{{ detail.user_uuid }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="性别">{{ enumLabel(USER_SEX, detail.sex) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="手机号">{{ formatText(detail.phone) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="enumTag(USER_STATUS, detail.status)" size="small">
|
||||||
|
{{ enumLabel(USER_STATUS, detail.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="detail.ban_reason" label="封禁原因">
|
||||||
|
{{ detail.ban_reason }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="detail.ban_start" label="封禁开始">
|
||||||
|
{{ formatDate(detail.ban_start) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="detail.ban_expire" label="封禁到期">
|
||||||
|
{{ formatDate(detail.ban_expire) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="会员类型">
|
||||||
|
<el-tag :type="enumTag(VIP_TYPE, detail.vip_type)" size="small">
|
||||||
|
{{ enumLabel(VIP_TYPE, detail.vip_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="会员到期">{{ formatDate(detail.vip_expire) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最后登录">{{ formatDate(detail.last_login_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="注册时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<h4 class="section-title">额度信息</h4>
|
||||||
|
<el-table :data="detail.quotas" size="small" border>
|
||||||
|
<el-table-column label="类型">
|
||||||
|
<template #default="{ row }">{{ enumLabel(QUOTA_TYPE, row.quota_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="总量">
|
||||||
|
<template #default="{ row }">{{ row.total }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已用">
|
||||||
|
<template #default="{ row }">{{ row.used }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余">
|
||||||
|
<template #default="{ row }">{{ row.remain }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<h4 class="section-title">额度凭证</h4>
|
||||||
|
<el-table :data="detail.grants" size="small" border>
|
||||||
|
<el-table-column label="类型">
|
||||||
|
<template #default="{ row }">{{ enumLabel(QUOTA_TYPE, row.quota_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="来源">
|
||||||
|
<template #default="{ row }">{{ enumLabel(GRANT_SOURCE, row.source) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数量">
|
||||||
|
<template #default="{ row }">{{ row.amount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已用">
|
||||||
|
<template #default="{ row }">{{ row.used }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="到期">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.expire_time === 0 ? '跟随 VIP' : formatDate(row.expire_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关联 ID">
|
||||||
|
<template #default="{ row }">{{ formatText(row.ref_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleRevokeGrant(row)">回收</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无凭证" :image-size="60" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 封禁对话框 -->
|
||||||
|
<el-dialog v-model="banVisible" :title="`封禁用户 · ${banTarget?.username ?? ''}`" width="440px">
|
||||||
|
<el-form ref="banFormRef" :model="banForm" :rules="banRules" label-width="88px">
|
||||||
|
<el-form-item label="封禁类型" prop="ban_type">
|
||||||
|
<el-radio-group v-model="banForm.ban_type">
|
||||||
|
<el-radio v-for="opt in enumOptions(BAN_TYPE)" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="banForm.ban_type === 1" label="封禁天数" prop="ban_days">
|
||||||
|
<el-input-number v-model="banForm.ban_days" :min="1" :max="3650" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="封禁原因" prop="reason">
|
||||||
|
<el-input v-model="banForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写封禁原因(必填)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="banVisible = false">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="banLoading" @click="submitBan">确认封禁</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 额度发放对话框 -->
|
||||||
|
<el-dialog v-model="quotaVisible" :title="`额度发放 · ${quotaTarget?.username ?? ''}`" width="440px">
|
||||||
|
<el-form ref="quotaFormRef" :model="quotaForm" :rules="quotaRules" label-width="88px">
|
||||||
|
<el-form-item label="额度类型" prop="quota_type">
|
||||||
|
<el-select v-model="quotaForm.quota_type" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(QUOTA_TYPE)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发放数量" prop="amount">
|
||||||
|
<el-input-number v-model="quotaForm.amount" :min="1" :max="100000000" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="有效期天数" prop="expire_days">
|
||||||
|
<el-input-number v-model="quotaForm.expire_days" :min="1" :max="3650" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="quotaVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="quotaLoading" @click="submitQuota">确认发放</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- VIP 调整对话框 -->
|
||||||
|
<el-dialog v-model="vipVisible" :title="`VIP 调整 · ${vipTarget?.username ?? ''}`" width="440px">
|
||||||
|
<el-form ref="vipFormRef" :model="vipForm" :rules="vipRules" label-width="88px">
|
||||||
|
<el-form-item label="会员类型" prop="vip_type">
|
||||||
|
<el-select v-model="vipForm.vip_type" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in enumOptions(VIP_TYPE).filter((o) => o.value !== 1)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="会员天数" prop="vip_days">
|
||||||
|
<el-input-number v-model="vipForm.vip_days" :min="0" :max="3650" controls-position="right" style="width: 100%" />
|
||||||
|
<div class="form-tip">天数为 0 表示取消会员;其余按当前到期时间顺延</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="调整原因" prop="reason">
|
||||||
|
<el-input v-model="vipForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写调整原因(必填)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="vipVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="vipLoading" @click="submitVip">确认调整</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cell-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-uuid {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa3c0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user