feat(admin-frontend): 新增用户管理页面和导航
This commit is contained in:
@@ -13,3 +13,17 @@
|
||||
- **[admin-frontend]**: 将登录页、主布局和仪表盘重构为 Apple 风格,并移除高成本视觉装饰以缓解页面卡顿 — by yinjianm
|
||||
- 方案: [202604210400_admin-frontend-apple-performance-refresh](archive/2026-04/202604210400_admin-frontend-apple-performance-refresh/)
|
||||
- 决策: admin-frontend-apple-performance-refresh#D001(采用 Apple 风格并优先性能减法), admin-frontend-apple-performance-refresh#D002(保留逻辑层只替换视图皮层)
|
||||
|
||||
## [0.1.2] - 2026-04-21
|
||||
|
||||
### 快速修改
|
||||
- **[admin-frontend]**: 修正仪表盘金额显示单位,将后端返回的“分”统一转换为“元”后再格式化 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: admin-frontend/src/utils/dashboard.ts:75
|
||||
|
||||
## [0.2.0] - 2026-04-21
|
||||
|
||||
### 新增
|
||||
- **[admin-frontend]**: 新增用户管理工作台、抽屉表单、用户操作菜单,以及“用户管理 / 工单管理”导航与路由骨架 — by yinjianm
|
||||
- 方案: [202604210441_admin-frontend-user-management](archive/2026-04/202604210441_admin-frontend-user-management/)
|
||||
- 决策: admin-frontend-user-management#D001(新增用户采用两段式创建), admin-frontend-user-management#D002(先补齐用户与工单入口结构)
|
||||
|
||||
@@ -11,11 +11,11 @@ active_package: 无
|
||||
|
||||
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
||||
- 当前重点模块: `admin-frontend`
|
||||
- 最新归档: `202604210326_admin-frontend-composio-dashboard`
|
||||
- 最新归档: `202604210441_admin-frontend-user-management`
|
||||
|
||||
## 活跃模块
|
||||
|
||||
- [admin-frontend](modules/admin-frontend.md): 管理端登录、主布局、仪表盘与管理 API 前端封装
|
||||
- [admin-frontend](modules/admin-frontend.md): 管理端登录、主布局、仪表盘、用户管理与管理 API 前端封装
|
||||
|
||||
## 归档与变更
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":8,"failed":0,"pending":0,"total":8,"done":8,"percent":100,"current":"已完成用户管理页面、路由与构建验证,待归档方案包","updated_at":"2026-04-21 05:05:00"}
|
||||
@@ -0,0 +1,208 @@
|
||||
# 变更提案: admin-frontend-user-management
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 新功能
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
创建: 2026-04-21
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前 `admin-frontend` 已完成登录、主布局与仪表盘,但业务路由仍只有 `/dashboard`。用户本轮明确要求继续沿 [.claude/plan/admin-frontend-login.md](/E:/code/php/Xboard-new/.claude/plan/admin-frontend-login.md) 推进,按照 [apple/DESIGN.md](/E:/code/php/Xboard-new/apple/DESIGN.md) 的 Apple 风格,为后台补齐“用户管理 / 工单管理”入口,且优先完整实现“用户管理”页面。参考图已经给出了目标交互形态,包括左侧菜单分组、用户列表、行内更多操作菜单,以及右侧抽屉式新增/编辑用户表单。
|
||||
|
||||
### 目标
|
||||
- 在管理端新增“用户管理”业务页,完成菜单、路由、页面与真实接口接入。
|
||||
- 让用户管理页具备可用的搜索、分页、状态展示、更多操作菜单,以及新增/编辑用户抽屉。
|
||||
- 预留“工单管理”菜单与路由入口,使后台导航结构与参考图对齐,但本轮不展开工单业务实现。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮只完整实现“用户管理”,工单管理仅补路由入口和占位页
|
||||
性能约束: 保持当前轻量 Apple 风格,不新增重型表格或状态管理依赖
|
||||
兼容性约束: 保持现有 Vue3 + TypeScript + Vite + Element Plus 栈与 hash 路由模式
|
||||
业务约束: 后端接口沿用现有 `/api/v2/{secure_path}/user/*`、`/plan/fetch`,不改 Laravel API
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 管理端左侧导航新增“用户管理”分组,包含“用户管理”和“工单管理”两个入口。
|
||||
- [ ] 用户管理页可通过真实接口完成列表读取、分页、基础筛选、状态/套餐/流量展示。
|
||||
- [ ] 用户管理页支持新增用户、编辑用户、复制订阅地址、重置密钥、封禁和删除等操作入口,并带明确确认反馈。
|
||||
- [ ] `admin-frontend` 构建通过,新增页面在桌面和移动端都能正常访问。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
本轮采用“扩展现有管理壳层 + 新增用户管理业务模块”的方案:
|
||||
|
||||
1. 扩展管理端数据层
|
||||
在 `src/types/api.d.ts` 中补充用户、套餐、分页与表单类型;在 `src/api/admin.ts` 中新增用户列表、用户详情、套餐列表、用户创建/更新/重置密钥/封禁/删除等请求封装。
|
||||
|
||||
2. 新增用户管理视图
|
||||
在 `src/views/users/` 下拆分页面与抽屉组件。列表页负责搜索、表格、分页、更多操作菜单;抽屉组件负责新增/编辑表单。视觉上延续 Apple 风格的浅灰画布、白色内容区与单一蓝色交互重点。
|
||||
|
||||
3. 对齐后端真实创建能力
|
||||
后端 `user/generate` 只能直接创建基础字段(邮箱、密码、套餐、到期时间),无法一次性写入完整表单字段。因此新增用户时采用“两段式”流程:
|
||||
- 先调用 `user/generate` 创建基础账号
|
||||
- 再按邮箱回查用户 ID,并调用 `user/update` 补齐流量、余额、佣金、权限、限速、设备数、备注等扩展字段
|
||||
|
||||
4. 补齐导航与路由
|
||||
将当前仅有仪表盘的侧边栏调整为分组导航,新增 `/users` 路由和 `/tickets` 占位路由;本轮仅实现 `UsersView` 的完整业务功能。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- admin-frontend/src/router: 新增用户管理与工单管理路由
|
||||
- admin-frontend/src/layouts: 调整侧边栏菜单结构与导航文案
|
||||
- admin-frontend/src/api: 扩展用户与套餐相关请求
|
||||
- admin-frontend/src/types: 新增用户管理数据类型
|
||||
- admin-frontend/src/views/users: 新增用户列表页与表单抽屉
|
||||
- admin-frontend/src/views/tickets: 新增工单管理占位页
|
||||
预计变更文件: 7-9
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 后端新增用户接口字段不足,无法一次提交完整表单 | 中 | 前端采用 `generate + fetch + update` 两段式创建流程 |
|
||||
| 参考图中的“在线设备”字段后端列表接口未直接返回 | 中 | 本轮优先展示可用业务字段,设备相关展示使用已有限制字段和可退化文案 |
|
||||
| 用户删除属于破坏性操作 | 中 | 在前端增加显式确认和操作完成提示,避免误删 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计(可选)
|
||||
|
||||
> 涉及路由扩展、API映射与表单编排,需填写。
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[AdminLayout 侧边栏菜单] --> B[UsersView 用户管理页]
|
||||
A --> C[TicketsPlaceholderView 工单占位页]
|
||||
B --> D[UserToolbar 搜索与操作]
|
||||
B --> E[UserTable 列表与更多操作]
|
||||
B --> F[UserFormDrawer 新增/编辑抽屉]
|
||||
B --> G[admin.ts 用户管理接口]
|
||||
F --> G
|
||||
G --> H[/user/fetch]
|
||||
G --> I[/user/generate]
|
||||
G --> J[/user/update]
|
||||
G --> K[/user/resetSecret]
|
||||
G --> L[/user/ban]
|
||||
G --> M[/user/destroy]
|
||||
G --> N[/plan/fetch]
|
||||
```
|
||||
|
||||
### API设计
|
||||
#### ANY /api/v2/{secure_path}/user/fetch
|
||||
- **请求**: `current`, `pageSize`, `filter[]`, `sort[]`
|
||||
- **响应**: `{ data: UserListItem[], total: number }`
|
||||
|
||||
#### GET /api/v2/{secure_path}/user/getUserInfoById
|
||||
- **请求**: `id`
|
||||
- **响应**: 单个用户详情,含邀请人信息
|
||||
|
||||
#### POST /api/v2/{secure_path}/user/generate
|
||||
- **请求**: `email_prefix`, `email_suffix`, `password`, `plan_id`, `expired_at`
|
||||
- **响应**: `success(true)` 或批量结果
|
||||
|
||||
#### POST /api/v2/{secure_path}/user/update
|
||||
- **请求**: `id` + 用户扩展字段(余额、佣金、流量、权限、限速、设备数、备注等)
|
||||
- **响应**: `success(true)`
|
||||
|
||||
#### GET /api/v2/{secure_path}/plan/fetch
|
||||
- **请求**: 无
|
||||
- **响应**: 套餐列表,用于表单选择和表格展示
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| AdminUserListItem | object | 用户列表行数据,包含套餐、邀请人、权限组、流量、状态等 |
|
||||
| AdminUserFormModel | object | 抽屉表单模型,覆盖新增/编辑时的基础与扩展字段 |
|
||||
| AdminPlanOption | object | 订阅计划选项,用于表单下拉与列表展示 |
|
||||
| AdminPaginationResult<T> | object | 用户列表分页结果 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
> 执行完成后同步到对应模块文档
|
||||
|
||||
### 场景: 浏览用户列表
|
||||
**模块**: UsersView
|
||||
**条件**: 管理员已登录并进入 `/users`
|
||||
**行为**: 页面读取用户列表、套餐信息并渲染搜索栏、表格、分页
|
||||
**结果**: 管理员可快速查看用户状态、流量、到期时间与套餐
|
||||
|
||||
### 场景: 新增用户
|
||||
**模块**: UserFormDrawer / admin.ts
|
||||
**条件**: 管理员在用户管理页点击“创建用户”
|
||||
**行为**: 管理员填写抽屉表单,前端先生成基础账号,再补写扩展字段
|
||||
**结果**: 新用户创建成功,列表自动刷新并提示结果
|
||||
|
||||
### 场景: 编辑或执行行内操作
|
||||
**模块**: UsersView / UserFormDrawer
|
||||
**条件**: 列表中存在目标用户
|
||||
**行为**: 管理员打开更多菜单执行编辑、复制订阅地址、重置密钥、封禁或删除
|
||||
**结果**: 对应操作完成并反馈到列表状态
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
|
||||
|
||||
### admin-frontend-user-management#D001: 新增用户采用“两段式创建”以兼容现有后端接口
|
||||
**日期**: 2026-04-21
|
||||
**状态**: ✅采纳
|
||||
**背景**: 参考表单包含余额、佣金、流量、角色、限速、设备数、备注等字段,但后端 `user/generate` 仅支持基础创建字段。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 仅用 `user/generate` 并缩减表单字段 | 实现简单 | 无法对齐参考页字段深度 |
|
||||
| B: 先 `generate`,再按邮箱回查并 `update` | 能保留完整表单能力 | 前端流程更复杂 |
|
||||
**决策**: 选择方案 B
|
||||
**理由**: 既不改后端,也能最大化还原参考抽屉中的管理能力。
|
||||
**影响**: `admin.ts`、`UsersView`、`UserFormDrawer`
|
||||
|
||||
### admin-frontend-user-management#D002: 先补齐用户/工单路由结构,但本轮仅交付完整用户页
|
||||
**日期**: 2026-04-21
|
||||
**状态**: ✅采纳
|
||||
**背景**: 用户要求“添加用户管理、工单管理路由,先完成用户管理”,既要有完整导航结构,又要控制当前实现范围。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 只加用户管理,不处理工单入口 | 实现最小 | 与用户点名的导航结构不一致 |
|
||||
| B: 同时补齐用户/工单入口,工单先占位 | 与目标结构一致,后续扩展顺滑 | 需要新增一个占位页面 |
|
||||
**决策**: 选择方案 B
|
||||
**理由**: 先把信息架构铺平,后续实现工单页时不必再改侧边栏和路由骨架。
|
||||
**影响**: `router/index.ts`、`layouts/AdminLayout.vue`
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
> 含视觉产出的任务由 DESIGN Phase2 填充。非视觉任务整节标注"N/A"。
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: Apple Admin Ledger。像 Apple 系统设置和内部运营面板的结合体,用极少的颜色和干净层级承载高密度数据。
|
||||
- **记忆点**: 浅灰页面基底上嵌入一块大尺寸白色数据工作台,右侧抽屉像系统级面板一样滑入。
|
||||
- **参考**: 用户提供的用户列表、操作菜单、抽屉表单和侧边栏截图 + [apple/DESIGN.md](/E:/code/php/Xboard-new/apple/DESIGN.md)
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 背景 `#f5f5f7`、表格/抽屉 `#ffffff`、标题 `#1d1d1f`、强调蓝 `#0071e3`、危险态 `#c93428`
|
||||
- **字体**: 延续当前系统字体栈 `-apple-system`, `BlinkMacSystemFont`, `SF Pro Display`, `SF Pro Text`, `Helvetica Neue`, Arial, sans-serif`
|
||||
- **布局**: 页面顶部为标题与操作条,中部为单块白色表格工作区;抽屉从右侧进入,表单按字段组自然分段
|
||||
- **动效**: 仅保留表格 hover、高亮状态和抽屉进出动画,避免复杂动效
|
||||
- **氛围**: 轻边框、软阴影、玻璃顶栏,避免深色重装饰和泛滥卡片分割
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: 所有状态色均保留文字标签;删除、封禁等危险操作必须有二次确认
|
||||
- **响应式**: 窄屏下工具栏折行、表格横向滚动、抽屉宽度自适应到视口
|
||||
@@ -0,0 +1,62 @@
|
||||
# 任务清单: admin-frontend-user-management
|
||||
|
||||
> **@status:** completed | 2026-04-21 05:02
|
||||
|
||||
```yaml
|
||||
@feature: admin-frontend-user-management
|
||||
@created: 2026-04-21
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 8 | 0 | 0 | 8 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 数据层与类型
|
||||
|
||||
- [√] 1.1 在 `admin-frontend/src/types/api.d.ts` 中补充用户管理、套餐、分页和表单类型 | depends_on: []
|
||||
- [√] 1.2 在 `admin-frontend/src/api/admin.ts` 中新增用户列表、详情、创建、更新、套餐、重置密钥、封禁、删除等接口封装 | depends_on: [1.1]
|
||||
|
||||
### 2. 用户管理视图
|
||||
|
||||
- [√] 2.1 新增 `admin-frontend/src/views/users/UserFormDrawer.vue`,实现新增/编辑用户抽屉表单和两段式创建流程 | depends_on: [1.1,1.2]
|
||||
- [√] 2.2 新增 `admin-frontend/src/views/users/UsersView.vue`,实现搜索、列表、分页、状态展示和更多操作菜单 | depends_on: [1.1,1.2]
|
||||
- [√] 2.3 在 `admin-frontend/src/views/users/UsersView.vue` 中完成抽屉联动、复制订阅地址、重置密钥、封禁、删除与刷新反馈 | depends_on: [2.1,2.2]
|
||||
|
||||
### 3. 导航与路由
|
||||
|
||||
- [√] 3.1 在 `admin-frontend/src/router/index.ts` 与 `admin-frontend/src/layouts/AdminLayout.vue` 中补齐“用户管理 / 工单管理”菜单和路由结构 | depends_on: [2.2]
|
||||
- [√] 3.2 新增 `admin-frontend/src/views/tickets/TicketsView.vue` 作为工单管理占位页,保持后续扩展入口稳定 | depends_on: [3.1]
|
||||
|
||||
### 4. 验收
|
||||
|
||||
- [√] 4.1 完成 `admin-frontend` 构建验证并修正新增页面引入的问题 | depends_on: [2.3,3.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-21 04:41 | 方案包初始化 | completed | 已确认本轮完整实现用户管理,工单管理仅补入口与占位页 |
|
||||
| 2026-04-21 04:49 | 1.1 / 1.2 | completed | 已补齐用户管理类型定义与 admin API 封装 |
|
||||
| 2026-04-21 04:54 | 2.1 / 2.2 / 2.3 | completed | 已完成用户列表页、抽屉表单和更多操作菜单联动 |
|
||||
| 2026-04-21 04:56 | 3.1 / 3.2 | completed | 已补齐用户管理 / 工单管理菜单与路由,并新增工单占位页 |
|
||||
| 2026-04-21 05:00 | 4.1 | completed | `npm run build` 通过;使用管理员账号验证登录后已跳转 `/users`,静态 preview 下真实数据请求因缺少 Laravel 运行环境未完成 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 记录执行过程中的重要说明、决策变更、风险提示等
|
||||
|
||||
- 本轮新增用户需要兼容后端 `user/generate` 的字段限制,前端会做创建后补写。
|
||||
- 参考图中的工单管理仅作为下一步入口,本轮不实现工单列表、会话与回复逻辑。
|
||||
- 真实数据接口联调受当前终端无 `php` 运行环境限制,浏览器验证覆盖到登录成功、路由跳转和页面结构渲染,未覆盖 Laravel 注入的 `window.settings` 与真实后台数据返回。
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202604210441 | admin-frontend-user-management | - | - | - | ✅完成 |
|
||||
| 202604210400 | admin-frontend-apple-performance-refresh | implementation | admin-frontend | admin-frontend-apple-performance-refresh#D001,#D002 | ✅完成 |
|
||||
| 202604210326 | admin-frontend-composio-dashboard | implementation | admin-frontend | admin-frontend-composio-dashboard#D001,#D002 | ✅完成 |
|
||||
| 202604180040 | optimize-docker-publish-workflow | - | - | - | ✅完成 |
|
||||
@@ -17,6 +18,7 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-04
|
||||
- [202604210441_admin-frontend-user-management](./2026-04/202604210441_admin-frontend-user-management/) - 新增用户管理工作台、抽屉表单、用户操作菜单,以及“用户管理 / 工单管理”导航与路由骨架
|
||||
- [202604210400_admin-frontend-apple-performance-refresh](./2026-04/202604210400_admin-frontend-apple-performance-refresh/) - Apple 风格重构登录页、主布局和仪表盘,并移除高成本装饰层以缓解卡顿
|
||||
- [202604210326_admin-frontend-composio-dashboard](./2026-04/202604210326_admin-frontend-composio-dashboard/) - 深色 Composio 风格管理端仪表盘、登录回跳与真实统计数据接入
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
- `stat/getTrafficRank`
|
||||
- `system/getSystemStatus`
|
||||
- `system/getQueueStats`
|
||||
- 管理端用户管理现已接入:
|
||||
- `user/fetch`
|
||||
- `user/generate`
|
||||
- `user/update`
|
||||
- `user/resetSecret`
|
||||
- `user/destroy`
|
||||
- `plan/fetch`
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -27,10 +34,11 @@
|
||||
## 开发约定
|
||||
|
||||
- 管理端路由使用 Hash 模式
|
||||
- 管理端当前业务路由包含 `/dashboard`、`/users` 与 `/tickets`
|
||||
- Bearer Token 存储于 `sessionStorage/localStorage`
|
||||
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
|
||||
|
||||
## 当前约束
|
||||
|
||||
- 本地预览环境默认缺少真实 `secure_path` 与管理员凭证
|
||||
- 本地静态 preview 环境默认缺少 Laravel 注入的 `window.settings` 与真实管理 API,受保护页面只能验证结构与跳转,不能替代完整联调
|
||||
- 后端接口契约以仓库内 Controller/Route 为准,不在前端推断字段
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
| 模块名 | 说明 | 最近更新 |
|
||||
|--------|------|----------|
|
||||
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘与管理 API 封装 | 2026-04-21 |
|
||||
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理与管理 API 封装 | 2026-04-21 |
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
## 职责
|
||||
|
||||
- 提供 Vue3 管理端登录页、认证状态、路由守卫和主布局
|
||||
- 封装管理端统计/系统状态接口
|
||||
- 渲染后台仪表盘、排行、队列和系统运行状态
|
||||
- 封装管理端统计/系统状态、用户管理和套餐查询接口
|
||||
- 渲染后台仪表盘、用户管理工作台,以及预留的工单管理入口
|
||||
|
||||
## 行为规范
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
- 受保护路由在未登录时会自动附加 `redirect` 查询参数
|
||||
- API 基础路径使用 `/api/v2/{secure_path}`,其中 `secure_path` 来自运行时配置
|
||||
- 仪表盘以真实后端接口返回值为准,不在前端伪造业务统计
|
||||
- 用户管理页通过真实后端 `user/fetch`、`user/update`、`user/generate`、`user/resetSecret`、`user/destroy` 与 `plan/fetch` 完成数据读写
|
||||
- 新增用户时采用“先 generate,后按邮箱回查并 update”的两段式流程,以兼容后端基础创建接口
|
||||
- 当前首页视觉基线为 Apple 风格:纯色分区、系统字体栈、单一蓝色强调和轻量层次
|
||||
- 性能优化优先级高于装饰性表达,避免远程字体、全局模糊背景和固定特效层
|
||||
|
||||
## 依赖关系
|
||||
|
||||
- 依赖 `src/api/client.ts` 处理 axios 与认证头
|
||||
- 依赖 `src/utils/users.ts` 负责用户管理表单转换、筛选组装和状态计算
|
||||
- 依赖 Laravel 注入的 `window.settings`
|
||||
- 构建输出到 `public/assets/admin`
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# 变更提案: admin-frontend-ticket-management
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 新功能/修复/重构/优化
|
||||
方案类型: implementation
|
||||
优先级: P0/P1/P2/P3
|
||||
状态: 草稿
|
||||
创建: 2026-04-21
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
{为什么需要这个变更}
|
||||
|
||||
### 目标
|
||||
{要达成什么目标}
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: {如有}
|
||||
性能约束: {如有}
|
||||
兼容性约束: {如有}
|
||||
业务约束: {如有}
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] {标准1}
|
||||
- [ ] {标准2}
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
{简要描述实现方式}
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- {模块1}: {影响说明}
|
||||
预计变更文件: {数量}
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| {风险} | 高/中/低 | {措施} |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计(可选)
|
||||
|
||||
> 涉及架构变更、API设计、数据模型变更时填写
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[组件A] --> B[组件B]
|
||||
```
|
||||
|
||||
### API设计
|
||||
#### {METHOD} {路径}
|
||||
- **请求**: {结构}
|
||||
- **响应**: {结构}
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| {字段} | {类型} | {说明} |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
> 执行完成后同步到对应模块文档
|
||||
|
||||
### 场景: {场景名称}
|
||||
**模块**: {所属模块}
|
||||
**条件**: {前置条件}
|
||||
**行为**: {操作描述}
|
||||
**结果**: {预期结果}
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
|
||||
|
||||
### admin-frontend-ticket-management#D001: {决策标题}
|
||||
**日期**: 2026-04-21
|
||||
**状态**: ✅采纳 / ❌废弃 / ⏸搁置
|
||||
**背景**: {为什么需要这个决策}
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: {方案A} | {优点} | {缺点} |
|
||||
| B: {方案B} | {优点} | {缺点} |
|
||||
**决策**: 选择方案{X}
|
||||
**理由**: {详细理由}
|
||||
**影响**: {对哪些模块有影响}
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
> 含视觉产出的任务由 DESIGN Phase2 填充。非视觉任务整节标注"N/A"。
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: {鲜明的美学方向名称+具体描述 — 禁止"现代简洁"等空泛词}
|
||||
- **记忆点**: {这个设计最令人难忘的一个特征}
|
||||
- **参考**: {URL/截图/风格描述/无}
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: {主色+强调色+背景色,色值或方向}
|
||||
- **字体**: {展示字体(标题)+正文字体配对+选择理由,避免 Arial/Inter/Roboto 等通用字体}
|
||||
- **布局**: {整体结构+空间策略}
|
||||
- **动效**: {入场动画/状态切换/交互反馈的动态效果策略}
|
||||
- **氛围**: {背景处理/纹理/阴影/透明叠层等纵深细节}
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: {对比度/语义化/导航要求/N/A}
|
||||
- **响应式**: {断点/适配策略/N/A}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 任务清单: admin-frontend-ticket-management
|
||||
|
||||
```yaml
|
||||
@feature: admin-frontend-ticket-management
|
||||
@created: 2026-04-21
|
||||
@status: pending
|
||||
@mode: {R2|R3}
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 0 | 0 | 0 | X |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. {阶段/模块名称}
|
||||
|
||||
- [ ] 1.1 在 `{文件路径}` 中实现 {具体功能}
|
||||
- [ ] 1.2 在 `{文件路径}` 中实现 {具体功能}
|
||||
- 依赖: 1.1
|
||||
|
||||
### 2. {阶段/模块名称}
|
||||
|
||||
- [ ] 2.1 {任务描述}
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 记录执行过程中的重要说明、决策变更、风险提示等
|
||||
@@ -1 +1 @@
|
||||
2026-04-21T03:13:51.504502
|
||||
2026-04-21T04:32:43.019652
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE_URL=/api/v2
|
||||
VITE_ADMIN_PATH=
|
||||
VITE_ADMIN_PATH=adminadmin
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { adminClient } from './client'
|
||||
import type {
|
||||
AdminPaginationResult,
|
||||
AdminPlanOption,
|
||||
AdminUserFetchParams,
|
||||
AdminUserGeneratePayload,
|
||||
AdminUserListItem,
|
||||
AdminUserUpdatePayload,
|
||||
ApiResponse,
|
||||
DashboardStats,
|
||||
OrderTrendData,
|
||||
@@ -14,6 +20,25 @@ function unwrap<T>(url: string, params?: Record<string, unknown>): Promise<ApiRe
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
function unwrapPost<T>(url: string, data?: Record<string, unknown>): Promise<ApiResponse<T>> {
|
||||
return adminClient
|
||||
.post<ApiResponse<T>>(url, data)
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
function splitEmail(email: string): { email_prefix: string; email_suffix: string } {
|
||||
const normalized = email.trim()
|
||||
const atIndex = normalized.lastIndexOf('@')
|
||||
if (atIndex <= 0 || atIndex === normalized.length - 1) {
|
||||
throw new Error('请输入有效的邮箱地址')
|
||||
}
|
||||
|
||||
return {
|
||||
email_prefix: normalized.slice(0, atIndex),
|
||||
email_suffix: normalized.slice(atIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function getDashboardStats(): Promise<ApiResponse<DashboardStats>> {
|
||||
return unwrap<DashboardStats>('/stat/getStats')
|
||||
}
|
||||
@@ -53,3 +78,39 @@ export function getSystemStatus(): Promise<ApiResponse<SystemStatus>> {
|
||||
export function getQueueStats(): Promise<ApiResponse<QueueStats>> {
|
||||
return unwrap<QueueStats>('/system/getQueueStats')
|
||||
}
|
||||
|
||||
export function getPlans(): Promise<ApiResponse<AdminPlanOption[]>> {
|
||||
return unwrap<AdminPlanOption[]>('/plan/fetch')
|
||||
}
|
||||
|
||||
export function fetchUsers(params: AdminUserFetchParams): Promise<AdminPaginationResult<AdminUserListItem>> {
|
||||
return adminClient
|
||||
.get<AdminPaginationResult<AdminUserListItem>>('/user/fetch', { params })
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function getUserById(id: number): Promise<ApiResponse<AdminUserListItem>> {
|
||||
return unwrap<AdminUserListItem>('/user/getUserInfoById', { id })
|
||||
}
|
||||
|
||||
export function createUser(payload: AdminUserGeneratePayload): Promise<ApiResponse<boolean>> {
|
||||
const email = splitEmail(payload.email)
|
||||
return unwrapPost<boolean>('/user/generate', {
|
||||
...email,
|
||||
password: payload.password,
|
||||
plan_id: payload.plan_id,
|
||||
expired_at: payload.expired_at,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateUser(payload: AdminUserUpdatePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/user/update', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function resetUserSecret(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/user/resetSecret', { id })
|
||||
}
|
||||
|
||||
export function deleteUser(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/user/destroy', { id })
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
Odometer,
|
||||
Tickets,
|
||||
SwitchButton,
|
||||
Fold,
|
||||
Expand,
|
||||
User,
|
||||
UserFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -24,6 +27,11 @@ const menuItems = [
|
||||
{ index: '/dashboard', title: '仪表盘', icon: Odometer },
|
||||
]
|
||||
|
||||
const managementItems = [
|
||||
{ index: '/users', title: '用户管理', icon: User },
|
||||
{ index: '/tickets', title: '工单管理', icon: Tickets },
|
||||
]
|
||||
|
||||
function syncViewport() {
|
||||
isMobile.value = window.innerWidth < 960
|
||||
if (isMobile.value) {
|
||||
@@ -66,20 +74,37 @@ onBeforeUnmount(() => {
|
||||
|
||||
<ElMenu
|
||||
:default-active="route.path"
|
||||
:default-openeds="['management']"
|
||||
:collapse="app.sidebarCollapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="admin-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<ElMenuItem
|
||||
v-for="item in menuItems"
|
||||
>
|
||||
<ElMenuItem
|
||||
v-for="item in menuItems"
|
||||
:key="item.index"
|
||||
:index="item.index"
|
||||
>
|
||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</ElMenuItem>
|
||||
>
|
||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</ElMenuItem>
|
||||
|
||||
<ElSubMenu index="management">
|
||||
<template #title>
|
||||
<ElIcon><UserFilled /></ElIcon>
|
||||
<span>用户管理</span>
|
||||
</template>
|
||||
|
||||
<ElMenuItem
|
||||
v-for="item in managementItems"
|
||||
:key="item.index"
|
||||
:index="item.index"
|
||||
>
|
||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</ElMenuItem>
|
||||
</ElSubMenu>
|
||||
</ElMenu>
|
||||
</ElAside>
|
||||
|
||||
@@ -191,6 +216,16 @@ onBeforeUnmount(() => {
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.el-sub-menu__title) {
|
||||
border-radius: 12px;
|
||||
color: var(--xboard-text-secondary);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.el-sub-menu .el-menu-item) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.admin-stage {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||
meta: { title: '仪表盘', kicker: 'Overview' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/users/UsersView.vue'),
|
||||
meta: { title: '用户管理', kicker: 'Users' },
|
||||
},
|
||||
{
|
||||
path: 'tickets',
|
||||
name: 'Tickets',
|
||||
component: () => import('@/views/tickets/TicketsView.vue'),
|
||||
meta: { title: '工单管理', kicker: 'Tickets' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Vendored
+105
@@ -116,6 +116,111 @@ export interface QueueStats {
|
||||
wait?: QueueWaitEntry[]
|
||||
}
|
||||
|
||||
export interface AdminPaginationResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AdminGroupOption {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AdminPlanOption {
|
||||
id: number
|
||||
name: string
|
||||
sort?: number
|
||||
transfer_enable?: number | null
|
||||
group_id?: number | null
|
||||
users_count?: number
|
||||
active_users_count?: number
|
||||
group?: AdminGroupOption | null
|
||||
}
|
||||
|
||||
export interface AdminUserRef {
|
||||
id: number
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
id: number
|
||||
email: string
|
||||
token: string
|
||||
uuid: string
|
||||
plan_id: number | null
|
||||
group_id: number | null
|
||||
transfer_enable: number
|
||||
u: number
|
||||
d: number
|
||||
total_used: number
|
||||
expired_at: number | null
|
||||
balance: number
|
||||
commission_balance: number
|
||||
commission_rate: number | null
|
||||
commission_type: number | null
|
||||
discount: number | null
|
||||
speed_limit: number | null
|
||||
device_limit: number | null
|
||||
remarks: string | null
|
||||
banned: boolean
|
||||
is_admin: boolean
|
||||
is_staff: boolean
|
||||
created_at: number
|
||||
updated_at: number
|
||||
subscribe_url: string
|
||||
plan?: AdminPlanOption | null
|
||||
group?: AdminGroupOption | null
|
||||
invite_user?: AdminUserRef | null
|
||||
}
|
||||
|
||||
export interface AdminUserFilter {
|
||||
id: string
|
||||
value: string | number | boolean | Array<string | number>
|
||||
logic?: 'and' | 'or'
|
||||
}
|
||||
|
||||
export interface AdminUserSort {
|
||||
id: string
|
||||
desc: boolean
|
||||
}
|
||||
|
||||
export interface AdminUserFetchParams {
|
||||
current: number
|
||||
pageSize: number
|
||||
filter?: AdminUserFilter[]
|
||||
sort?: AdminUserSort[]
|
||||
}
|
||||
|
||||
export interface AdminUserGeneratePayload {
|
||||
email: string
|
||||
password: string
|
||||
plan_id?: number | null
|
||||
expired_at?: number | null
|
||||
}
|
||||
|
||||
export interface AdminUserUpdatePayload {
|
||||
id: number
|
||||
email?: string
|
||||
password?: string
|
||||
transfer_enable?: number
|
||||
expired_at?: number | null
|
||||
banned?: boolean | number
|
||||
plan_id?: number | null
|
||||
commission_rate?: number | null
|
||||
discount?: number | null
|
||||
is_admin?: boolean
|
||||
is_staff?: boolean
|
||||
u?: number
|
||||
d?: number
|
||||
balance?: number
|
||||
commission_type?: number | null
|
||||
commission_balance?: number
|
||||
remarks?: string | null
|
||||
speed_limit?: number | null
|
||||
device_limit?: number | null
|
||||
invite_user_email?: string | null
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
settings?: {
|
||||
|
||||
+18
@@ -16,15 +16,33 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
|
||||
|
||||
export interface UserStatusMeta {
|
||||
label: string
|
||||
type: 'success' | 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
export interface UserFormModel {
|
||||
id?: number
|
||||
email: string
|
||||
password: string
|
||||
uploadGb: number | null
|
||||
downloadGb: number | null
|
||||
totalTrafficGb: number | null
|
||||
expiredAt: number | null
|
||||
planId: number | null
|
||||
banned: boolean
|
||||
commissionType: number | null
|
||||
commissionRate: number | null
|
||||
discount: number | null
|
||||
speedLimit: number | null
|
||||
deviceLimit: number | null
|
||||
balance: number | null
|
||||
commissionBalance: number | null
|
||||
inviteUserEmail: string
|
||||
isAdmin: boolean
|
||||
isStaff: boolean
|
||||
remarks: string
|
||||
}
|
||||
|
||||
export const COMMISSION_TYPE_OPTIONS = [
|
||||
{ label: '跟随系统', value: 0 },
|
||||
{ label: '周期返佣', value: 1 },
|
||||
{ label: '一次性返佣', value: 2 },
|
||||
] as const
|
||||
|
||||
const GIGABYTE = 1024 ** 3
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : 0
|
||||
}
|
||||
|
||||
export function bytesToGigabytes(value: unknown): number | null {
|
||||
const numeric = toNumber(value)
|
||||
if (numeric <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Number((numeric / GIGABYTE).toFixed(2))
|
||||
}
|
||||
|
||||
export function gigabytesToBytes(value: number | null | undefined): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round(numeric * GIGABYTE)
|
||||
}
|
||||
|
||||
export function normalizeTimestampSeconds(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null
|
||||
}
|
||||
|
||||
export function splitEmailAddress(email: string): { prefix: string; suffix: string } | null {
|
||||
const normalized = email.trim()
|
||||
const atIndex = normalized.lastIndexOf('@')
|
||||
if (atIndex <= 0 || atIndex === normalized.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: normalized.slice(0, atIndex),
|
||||
suffix: normalized.slice(atIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserUsagePercent(user: Pick<AdminUserListItem, 'transfer_enable' | 'total_used'>): number {
|
||||
const total = toNumber(user.transfer_enable)
|
||||
if (total <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.min(100, Number(((toNumber(user.total_used) / total) * 100).toFixed(1)))
|
||||
}
|
||||
|
||||
export function getUserStatusMeta(user: Pick<AdminUserListItem, 'banned' | 'expired_at' | 'plan_id'>): UserStatusMeta {
|
||||
if (user.banned) {
|
||||
return { label: '封禁', type: 'danger' }
|
||||
}
|
||||
|
||||
if (!user.plan_id) {
|
||||
return { label: '未订阅', type: 'info' }
|
||||
}
|
||||
|
||||
if (user.expired_at && user.expired_at < Math.floor(Date.now() / 1000)) {
|
||||
return { label: '已到期', type: 'warning' }
|
||||
}
|
||||
|
||||
return { label: '正常', type: 'success' }
|
||||
}
|
||||
|
||||
export function createEmptyUserForm(): UserFormModel {
|
||||
return {
|
||||
email: '',
|
||||
password: '',
|
||||
uploadGb: null,
|
||||
downloadGb: null,
|
||||
totalTrafficGb: null,
|
||||
expiredAt: null,
|
||||
planId: null,
|
||||
banned: false,
|
||||
commissionType: 0,
|
||||
commissionRate: null,
|
||||
discount: null,
|
||||
speedLimit: null,
|
||||
deviceLimit: null,
|
||||
balance: null,
|
||||
commissionBalance: null,
|
||||
inviteUserEmail: '',
|
||||
isAdmin: false,
|
||||
isStaff: false,
|
||||
remarks: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function toUserFormModel(user?: AdminUserListItem | null): UserFormModel {
|
||||
if (!user) {
|
||||
return createEmptyUserForm()
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
password: '',
|
||||
uploadGb: bytesToGigabytes(user.u),
|
||||
downloadGb: bytesToGigabytes(user.d),
|
||||
totalTrafficGb: bytesToGigabytes(user.transfer_enable),
|
||||
expiredAt: normalizeTimestampSeconds(user.expired_at),
|
||||
planId: user.plan_id,
|
||||
banned: Boolean(user.banned),
|
||||
commissionType: user.commission_type ?? 0,
|
||||
commissionRate: user.commission_rate ?? null,
|
||||
discount: user.discount ?? null,
|
||||
speedLimit: user.speed_limit ?? null,
|
||||
deviceLimit: user.device_limit ?? null,
|
||||
balance: user.balance ?? null,
|
||||
commissionBalance: user.commission_balance ?? null,
|
||||
inviteUserEmail: user.invite_user?.email ?? '',
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
isStaff: Boolean(user.is_staff),
|
||||
remarks: user.remarks ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function toUserUpdatePayload(form: UserFormModel): AdminUserUpdatePayload {
|
||||
return {
|
||||
id: Number(form.id),
|
||||
email: form.email.trim(),
|
||||
password: form.password.trim() || undefined,
|
||||
u: gigabytesToBytes(form.uploadGb),
|
||||
d: gigabytesToBytes(form.downloadGb),
|
||||
transfer_enable: gigabytesToBytes(form.totalTrafficGb),
|
||||
expired_at: normalizeTimestampSeconds(form.expiredAt),
|
||||
plan_id: form.planId,
|
||||
banned: form.banned,
|
||||
commission_type: form.commissionType,
|
||||
commission_rate: form.commissionRate,
|
||||
discount: form.discount,
|
||||
speed_limit: form.speedLimit,
|
||||
device_limit: form.deviceLimit,
|
||||
balance: form.balance ?? 0,
|
||||
commission_balance: form.commissionBalance ?? 0,
|
||||
invite_user_email: form.inviteUserEmail.trim() || null,
|
||||
is_admin: form.isAdmin,
|
||||
is_staff: form.isStaff,
|
||||
remarks: form.remarks.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUserFilters(keyword: string, status: string, planId: string): Array<{ id: string; value: string | number[] }> {
|
||||
const filters: Array<{ id: string; value: string | number[] }> = []
|
||||
|
||||
if (keyword.trim()) {
|
||||
filters.push({ id: 'email', value: keyword.trim() })
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
filters.push({ id: 'banned', value: [0] })
|
||||
}
|
||||
|
||||
if (status === 'banned') {
|
||||
filters.push({ id: 'banned', value: [1] })
|
||||
}
|
||||
|
||||
if (planId && planId !== 'all') {
|
||||
filters.push({ id: 'plan_id', value: [Number(planId)] })
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
'工单列表与状态筛选',
|
||||
'工单会话详情与回复',
|
||||
'关闭工单与处理记录',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tickets-placeholder">
|
||||
<div class="placeholder-copy">
|
||||
<p>Tickets</p>
|
||||
<h1>工单管理将在下一步补齐。</h1>
|
||||
<span>本轮先把导航和路由结构铺平,避免后续再改后台信息架构。</span>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-card">
|
||||
<strong>预留能力</strong>
|
||||
<ul>
|
||||
<li v-for="item in features" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tickets-placeholder {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 32px;
|
||||
border-radius: 26px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.placeholder-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-copy p {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.placeholder-copy h1 {
|
||||
font-size: clamp(30px, 5vw, 44px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.placeholder-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
max-width: 620px;
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
padding: 22px 24px;
|
||||
border-radius: 22px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.placeholder-card strong {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.placeholder-card ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 18px;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { createUser, fetchUsers, updateUser } from '@/api/admin'
|
||||
import type { AdminPlanOption, AdminUserListItem } from '@/types/api'
|
||||
import {
|
||||
COMMISSION_TYPE_OPTIONS,
|
||||
buildUserFilters,
|
||||
createEmptyUserForm,
|
||||
splitEmailAddress,
|
||||
toUserFormModel,
|
||||
toUserUpdatePayload,
|
||||
type UserFormModel,
|
||||
} from '@/utils/users'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
user?: AdminUserListItem | null
|
||||
plans: AdminPlanOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: [message: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive<UserFormModel>(createEmptyUserForm())
|
||||
|
||||
const drawerTitle = computed(() => props.mode === 'create' ? '创建用户' : '编辑用户')
|
||||
|
||||
const rules = computed<FormRules<UserFormModel>>(() => ({
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效邮箱', trigger: ['blur', 'change'] },
|
||||
],
|
||||
password: props.mode === 'create'
|
||||
? [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, message: '密码至少 8 位', trigger: 'blur' },
|
||||
]
|
||||
: [{ min: 8, message: '密码至少 8 位', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function closeDrawer() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function syncForm() {
|
||||
Object.assign(form, toUserFormModel(props.user))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const instance = formRef.value
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await instance.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
if (props.mode === 'create') {
|
||||
if (!splitEmailAddress(form.email)) {
|
||||
throw new Error('请输入有效的邮箱地址')
|
||||
}
|
||||
|
||||
await createUser({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
plan_id: form.planId,
|
||||
expired_at: form.expiredAt,
|
||||
})
|
||||
|
||||
const created = await fetchUsers({
|
||||
current: 1,
|
||||
pageSize: 1,
|
||||
filter: buildUserFilters(form.email, 'all', 'all'),
|
||||
})
|
||||
|
||||
const createdUser = created.data.find((item) => item.email === form.email)
|
||||
if (!createdUser) {
|
||||
throw new Error('用户已创建,但未能回查到新记录')
|
||||
}
|
||||
|
||||
await updateUser(toUserUpdatePayload({
|
||||
...form,
|
||||
id: createdUser.id,
|
||||
password: '',
|
||||
}))
|
||||
|
||||
ElMessage.success('用户已创建')
|
||||
emit('success', '用户已创建')
|
||||
closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
await updateUser(toUserUpdatePayload(form))
|
||||
ElMessage.success('用户资料已更新')
|
||||
emit('success', '用户资料已更新')
|
||||
closeDrawer()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.user, props.mode],
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
syncForm()
|
||||
formRef.value?.clearValidate()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="props.visible"
|
||||
:title="drawerTitle"
|
||||
size="min(520px, 100vw)"
|
||||
class="user-form-drawer"
|
||||
destroy-on-close
|
||||
@close="closeDrawer"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="drawer-shell">
|
||||
<div class="drawer-copy">
|
||||
<p>用户管理</p>
|
||||
<h2>{{ drawerTitle }}</h2>
|
||||
<span>表单字段与后端现有用户接口保持一致。</span>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="drawer-form"
|
||||
>
|
||||
<div class="drawer-grid drawer-grid--single">
|
||||
<ElFormItem label="邮箱" prop="email">
|
||||
<ElInput v-model="form.email" placeholder="请输入邮箱" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="密码" prop="password">
|
||||
<ElInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="props.mode === 'create' ? '创建时必填' : '留空则不修改'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<div class="drawer-grid">
|
||||
<ElFormItem label="余额">
|
||||
<ElInputNumber v-model="form.balance" :min="0" :precision="2" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="佣金余额">
|
||||
<ElInputNumber v-model="form.commissionBalance" :min="0" :precision="2" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="上传">
|
||||
<ElInputNumber v-model="form.uploadGb" :min="0" :precision="2" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="下载">
|
||||
<ElInputNumber v-model="form.downloadGb" :min="0" :precision="2" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="总流量">
|
||||
<ElInputNumber v-model="form.totalTrafficGb" :min="0" :precision="2" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="到期时间">
|
||||
<ElDatePicker
|
||||
v-model="form.expiredAt"
|
||||
type="datetime"
|
||||
value-format="X"
|
||||
placeholder="长期有效"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="订阅计划">
|
||||
<ElSelect v-model="form.planId" clearable placeholder="请选择订阅">
|
||||
<ElOption
|
||||
v-for="plan in props.plans"
|
||||
:key="plan.id"
|
||||
:label="plan.name"
|
||||
:value="plan.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="用户状态">
|
||||
<ElSelect v-model="form.banned" placeholder="请选择状态">
|
||||
<ElOption :value="false" label="正常" />
|
||||
<ElOption :value="true" label="封禁" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="佣金类型">
|
||||
<ElSelect v-model="form.commissionType" placeholder="请选择类型">
|
||||
<ElOption
|
||||
v-for="option in COMMISSION_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="推荐返利比例">
|
||||
<ElInputNumber v-model="form.commissionRate" :min="0" :max="100" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="专属折扣比例">
|
||||
<ElInputNumber v-model="form.discount" :min="0" :max="100" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="限速">
|
||||
<ElInputNumber v-model="form.speedLimit" :min="0" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="设备限制">
|
||||
<ElInputNumber v-model="form.deviceLimit" :min="0" :controls="false" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="邀请人邮箱">
|
||||
<ElInput v-model="form.inviteUserEmail" placeholder="请输入邀请人邮箱" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<div class="drawer-grid drawer-grid--toggles">
|
||||
<ElFormItem label="是否管理员">
|
||||
<ElSwitch v-model="form.isAdmin" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="是否员工">
|
||||
<ElSwitch v-model="form.isStaff" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem label="备注">
|
||||
<ElInput
|
||||
v-model="form.remarks"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入备注信息"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-actions">
|
||||
<ElButton @click="closeDrawer">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ props.mode === 'create' ? '提交创建' : '保存修改' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-shell {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.drawer-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-copy p {
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-copy h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.drawer-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.drawer-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.drawer-grid--single,
|
||||
.drawer-grid--toggles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.drawer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { MoreFilled, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
|
||||
import { deleteUser, fetchUsers, getPlans, resetUserSecret, updateUser } from '@/api/admin'
|
||||
import type { AdminPlanOption, AdminUserListItem } from '@/types/api'
|
||||
import { formatDateTime, formatTraffic } from '@/utils/dashboard'
|
||||
import { buildUserFilters, getUserStatusMeta, getUserUsagePercent } from '@/utils/users'
|
||||
import UserFormDrawer from './UserFormDrawer.vue'
|
||||
|
||||
type DrawerMode = 'create' | 'edit'
|
||||
type UserAction = 'edit' | 'copy' | 'reset-secret' | 'toggle-ban' | 'delete'
|
||||
|
||||
const loading = ref(false)
|
||||
const plansLoading = ref(false)
|
||||
const users = ref<AdminUserListItem[]>([])
|
||||
const plans = ref<AdminPlanOption[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const planFilter = ref('all')
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('create')
|
||||
const activeUser = ref<AdminUserListItem | null>(null)
|
||||
|
||||
const pageStats = computed(() => [
|
||||
{ label: '用户总数', value: String(total.value) },
|
||||
{ label: '当前页', value: String(current.value) },
|
||||
{ label: '已筛选套餐', value: planFilter.value === 'all' ? '全部' : '单套餐' },
|
||||
])
|
||||
|
||||
async function loadPlans() {
|
||||
plansLoading.value = true
|
||||
try {
|
||||
const response = await getPlans()
|
||||
plans.value = response.data ?? []
|
||||
} finally {
|
||||
plansLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetchUsers({
|
||||
current: current.value,
|
||||
pageSize: pageSize.value,
|
||||
filter: buildUserFilters(keyword.value, statusFilter.value, planFilter.value),
|
||||
sort: [{ id: 'id', desc: true }],
|
||||
})
|
||||
|
||||
users.value = response.data
|
||||
total.value = response.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
drawerMode.value = 'create'
|
||||
activeUser.value = null
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(user: AdminUserListItem) {
|
||||
drawerMode.value = 'edit'
|
||||
activeUser.value = user
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function copySubscribeUrl(user: AdminUserListItem) {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
ElMessage.warning('当前环境不支持复制,请手动复制订阅地址')
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(user.subscribe_url)
|
||||
ElMessage.success('订阅地址已复制')
|
||||
}
|
||||
|
||||
async function toggleBan(user: AdminUserListItem) {
|
||||
const nextValue = !user.banned
|
||||
const actionText = nextValue ? '封禁' : '恢复'
|
||||
|
||||
await ElMessageBox.confirm(`确认${actionText}用户 ${user.email} 吗?`, `${actionText}用户`, {
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
await updateUser({ id: user.id, banned: nextValue })
|
||||
ElMessage.success(`用户已${actionText}`)
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
async function handleAction(action: UserAction, user: AdminUserListItem) {
|
||||
if (action === 'edit') {
|
||||
openEditDrawer(user)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'copy') {
|
||||
await copySubscribeUrl(user)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'reset-secret') {
|
||||
await ElMessageBox.confirm(`确认重置 ${user.email} 的 UUID 与订阅地址吗?`, '重置密钥', {
|
||||
type: 'warning',
|
||||
})
|
||||
await resetUserSecret(user.id)
|
||||
ElMessage.success('UUID 与订阅地址已重置')
|
||||
await loadUsers()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'toggle-ban') {
|
||||
await toggleBan(user)
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(`删除用户 ${user.email} 后无法恢复,确认继续吗?`, '删除用户', {
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteUser(user.id)
|
||||
ElMessage.success('用户已删除')
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
current.value = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
keyword.value = ''
|
||||
statusFilter.value = 'all'
|
||||
planFilter.value = 'all'
|
||||
current.value = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
watch(pageSize, () => {
|
||||
current.value = 1
|
||||
void loadUsers()
|
||||
})
|
||||
|
||||
watch(current, () => {
|
||||
void loadUsers()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([loadPlans(), loadUsers()]).catch(() => {
|
||||
ElMessage.error('用户管理页面初始化失败')
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<section class="users-hero">
|
||||
<div class="users-copy">
|
||||
<p class="users-kicker">Users</p>
|
||||
<h1>用户管理工作台。</h1>
|
||||
<span>用一页完成搜索、筛选、编辑与账户维护,保留 Apple 风格的轻量信息层次。</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<article v-for="item in pageStats" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-shell">
|
||||
<header class="table-toolbar">
|
||||
<div class="toolbar-fields">
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索用户邮箱..."
|
||||
class="toolbar-input"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="用户状态">
|
||||
<ElOption label="全部状态" value="all" />
|
||||
<ElOption label="正常" value="active" />
|
||||
<ElOption label="封禁" value="banned" />
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect
|
||||
v-model="planFilter"
|
||||
class="toolbar-select"
|
||||
:loading="plansLoading"
|
||||
placeholder="订阅计划"
|
||||
>
|
||||
<ElOption label="全部订阅" value="all" />
|
||||
<ElOption
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:label="plan.name"
|
||||
:value="String(plan.id)"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<ElButton @click="handleReset">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
重置筛选
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="openCreateDrawer">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
创建用户
|
||||
</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ElTable :data="users" v-loading="loading" class="users-table" row-key="id">
|
||||
<ElTableColumn prop="id" label="ID" width="92" />
|
||||
<ElTableColumn label="邮箱" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="email-cell">
|
||||
<strong>{{ row.email }}</strong>
|
||||
<span>{{ row.group?.name || '未分组' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="108">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getUserStatusMeta(row).type" effect="plain" round>
|
||||
{{ getUserStatusMeta(row).label }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="订阅" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="stack-cell">
|
||||
<strong>{{ row.plan?.name || '无订阅' }}</strong>
|
||||
<span>{{ row.device_limit ? `设备限制 ${row.device_limit}` : '未设设备限制' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="已用流量" min-width="152">
|
||||
<template #default="{ row }">
|
||||
<div class="traffic-cell">
|
||||
<strong>{{ formatTraffic(row.total_used) }}</strong>
|
||||
<ElProgress
|
||||
:percentage="getUserUsagePercent(row)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
color="#0071e3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="总流量" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatTraffic(row.transfer_enable) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="余额" width="118">
|
||||
<template #default="{ row }">
|
||||
¥{{ Number(row.balance || 0).toFixed(2) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="到期时间" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.expired_at ? formatDateTime(row.expired_at) : '长期有效' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="104" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElDropdown trigger="click" @command="(command) => handleAction(command as UserAction, row)">
|
||||
<ElButton text class="action-trigger">
|
||||
<ElIcon><MoreFilled /></ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="edit">编辑</ElDropdownItem>
|
||||
<ElDropdownItem command="copy">复制订阅 URL</ElDropdownItem>
|
||||
<ElDropdownItem command="reset-secret">重置 UUID 及订阅 URL</ElDropdownItem>
|
||||
<ElDropdownItem command="toggle-ban">
|
||||
{{ row.banned ? '恢复正常' : '封禁用户' }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="delete" divided>删除</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<footer class="table-footer">
|
||||
<span>已加载 {{ users.length }} 条,共 {{ total }} 条</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<UserFormDrawer
|
||||
v-model:visible="drawerVisible"
|
||||
:mode="drawerMode"
|
||||
:user="activeUser"
|
||||
:plans="plans"
|
||||
@success="() => loadUsers()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.users-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.users-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.users-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.users-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.users-copy span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.hero-stats article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-stats span {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 26px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-fields,
|
||||
.toolbar-actions,
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.table-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-fields {
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.users-table :deep(th.el-table__cell) {
|
||||
color: var(--xboard-text-secondary);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.users-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.email-cell,
|
||||
.stack-cell,
|
||||
.traffic-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.email-cell strong,
|
||||
.stack-cell strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.email-cell span,
|
||||
.stack-cell span,
|
||||
.table-footer span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.traffic-cell {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.action-trigger {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.users-hero,
|
||||
.table-toolbar,
|
||||
.table-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user