first commit

This commit is contained in:
2026-09-11 01:46:36 +08:00
commit df4332a2a3
58 changed files with 9771 additions and 0 deletions
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+61
View File
@@ -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 并重放原请求
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -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>
+3187
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+33
View File
@@ -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}`)
}
+28
View File
@@ -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)
}
+56
View File
@@ -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`)
}
+12
View File
@@ -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 })
}
+34
View File
@@ -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}`)
}
+16
View File
@@ -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)
}
+34
View File
@@ -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)
}
+46
View File
@@ -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}`)
}
+35
View File
@@ -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;
}
+84
View File
@@ -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;
}
+55
View File
@@ -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>
+67
View File
@@ -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>
+53
View File
@@ -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>
+130
View File
@@ -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>
+233
View File
@@ -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>
+148
View File
@@ -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
View File
@@ -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')
+127
View File
@@ -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
+93
View File
@@ -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,
}
})
+52
View File
@@ -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
}
+21
View File
@@ -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[]
}
+99
View File
@@ -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
}
+99
View File
@@ -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[]
}
+61
View File
@@ -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
}
+14
View File
@@ -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
}
+58
View File
@@ -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
}
+68
View File
@@ -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
}
+176
View File
@@ -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 }))
}
+34
View File
@@ -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
}
+182
View File
@@ -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
+279
View File
@@ -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>
+711
View File
@@ -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>
+422
View File
@@ -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>
+191
View File
@@ -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>
+220
View File
@@ -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>
+192
View File
@@ -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>
+231
View File
@@ -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>
+241
View File
@@ -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>
+349
View File
@@ -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>
+142
View File
@@ -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>
+601
View File
@@ -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>
+18
View File
@@ -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"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+27
View File
@@ -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"
}
}
+27
View File
@@ -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,
},
},
},
})