feat(admin-frontend): 完成节点与礼品卡管理工作台
补齐节点管理真实新增、编辑与排序流程,接入权限组与路由组 维护页,并支持 11 种协议的动态配置表单 开放礼品卡管理入口,交付模板、兑换码、使用记录与统计四页签 工作台,接入 gift-card 相关后台接口 将知识库、权限组与路由管理从占位页升级为真实页面,并修复侧边栏 低高度裁切问题 修复仪表盘 24h 流量排行涨跌始终为 0 的问题,改为对比昨天整日统 计并补充单元测试
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"consecutive_failures": 5,
|
"consecutive_failures": 7,
|
||||||
"last_failure": "2026-04-24T08:27:44.702Z"
|
"last_failure": "2026-04-24T09:59:02.414Z"
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"updatedAt": "2026-04-24T08:26:56.724Z",
|
"updatedAt": "2026-04-24T09:54:56.581Z",
|
||||||
"source": "manual",
|
"source": "manual",
|
||||||
"originCommand": "fix-stash-anytls-compat-filter",
|
"originCommand": "generic-r2",
|
||||||
"requirementsCoverage": {
|
"requirementsCoverage": {
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"summary": "已按保守兼容策略在 Stash 导出器中过滤未知版本与低版本的 AnyTLS,并补方案包记录。"
|
"summary": "节点管理方案包中定义的新增、编辑、排序、11 种协议动态配置、动态倍率、权限组/路由组联动与构建验证均已落地。"
|
||||||
},
|
},
|
||||||
"deliveryChecklist": {
|
"deliveryChecklist": {
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"summary": "代码修复、静态回归测试文件、知识库同步与状态快照均已完成;运行时验证受 PHP 环境缺失阻塞并已显式记录。"
|
"summary": "admin-frontend 已完成节点工作台实现、npm run build 通过、知识库同步完成,并写入视觉与收尾证据。"
|
||||||
},
|
},
|
||||||
"fingerprint": {
|
"fingerprint": {
|
||||||
"available": true,
|
"available": true,
|
||||||
"unstaged": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 14 +-\n .helloagents/.ralph-review.json | 24 +-\n .helloagents/.ralph-visual.json | 36 ++-\n .helloagents/CHANGELOG.md | 63 +++++\n .helloagents/INDEX.md | 7 +-\n .helloagents/archive/_index.md | 10 +\n .helloagents/context.md | 26 +-\n .helloagents/modules/_index.md | 1 +\n .helloagents/modules/admin-frontend.md | 29 ++-\n .../2026-04-23T15-07-42-905Z-unknown-t2hj2g.jsonl | 18 --\n .helloagents/sessions/master/default/STATE.md | 21 +-\n admin-frontend/src/api/admin.ts | 229 +++++++++++++++++\n admin-frontend/src/layouts/AdminLayout.vue | 4 +-\n admin-frontend/src/router/index.ts | 22 +-\n admin-frontend/src/types/api.d.ts | 281 ++++++++++++++++++++-\n admin-frontend/src/utils/plans.ts | 9 +\n .../src/views/subscriptions/PlansView.vue | 13 +-\n app/Protocols/Stash.php | 21 ++\n public/assets/admin | 0\n 20 files changed, 740 insertions(+), 92 deletions(-)",
|
"unstaged": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 30 +-\n .helloagents/CHANGELOG.md | 42 +++\n .helloagents/INDEX.md | 2 +-\n .helloagents/archive/_index.md | 5 +\n .helloagents/context.md | 24 +-\n .helloagents/modules/admin-frontend.md | 21 +-\n .../.status.json | 2 +-\n .../tasks.md | 8 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 7 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 1 +\n .helloagents/sessions/master/default/STATE.md | 19 +-\n admin-frontend/src/api/admin.ts | 126 +++++++\n admin-frontend/src/layouts/AdminLayout.vue | 39 +-\n admin-frontend/src/router/index.ts | 6 +\n admin-frontend/src/types/api.d.ts | 216 +++++++++++-\n admin-frontend/src/types/components.d.ts | 2 +\n admin-frontend/src/utils/nodes.ts | 4 +-\n admin-frontend/src/views/nodes/NodeGroupsView.vue | 314 ++++++++++++-----\n admin-frontend/src/views/nodes/NodeRoutesView.vue | 357 ++++++++++++++-----\n admin-frontend/src/views/nodes/NodesView.vue | 102 +++++-\n .../src/views/system/KnowledgeEditorDialog.vue | 2 +-\n .../src/views/system/SystemKnowledgeView.scss | 69 ++--\n .../src/views/system/SystemKnowledgeView.vue | 391 ++++++++++++++++++++-\n public/assets/admin | 0\n 26 files changed, 1543 insertions(+), 262 deletions(-)",
|
||||||
"staged": "",
|
"staged": "",
|
||||||
"combined": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 14 +-\n .helloagents/.ralph-review.json | 24 +-\n .helloagents/.ralph-visual.json | 36 ++-\n .helloagents/CHANGELOG.md | 63 +++++\n .helloagents/INDEX.md | 7 +-\n .helloagents/archive/_index.md | 10 +\n .helloagents/context.md | 26 +-\n .helloagents/modules/_index.md | 1 +\n .helloagents/modules/admin-frontend.md | 29 ++-\n .../2026-04-23T15-07-42-905Z-unknown-t2hj2g.jsonl | 18 --\n .helloagents/sessions/master/default/STATE.md | 21 +-\n admin-frontend/src/api/admin.ts | 229 +++++++++++++++++\n admin-frontend/src/layouts/AdminLayout.vue | 4 +-\n admin-frontend/src/router/index.ts | 22 +-\n admin-frontend/src/types/api.d.ts | 281 ++++++++++++++++++++-\n admin-frontend/src/utils/plans.ts | 9 +\n .../src/views/subscriptions/PlansView.vue | 13 +-\n app/Protocols/Stash.php | 21 ++\n public/assets/admin | 0\n 20 files changed, 740 insertions(+), 92 deletions(-)\n---"
|
"combined": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 30 +-\n .helloagents/CHANGELOG.md | 42 +++\n .helloagents/INDEX.md | 2 +-\n .helloagents/archive/_index.md | 5 +\n .helloagents/context.md | 24 +-\n .helloagents/modules/admin-frontend.md | 21 +-\n .../.status.json | 2 +-\n .../tasks.md | 8 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 7 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 1 +\n .helloagents/sessions/master/default/STATE.md | 19 +-\n admin-frontend/src/api/admin.ts | 126 +++++++\n admin-frontend/src/layouts/AdminLayout.vue | 39 +-\n admin-frontend/src/router/index.ts | 6 +\n admin-frontend/src/types/api.d.ts | 216 +++++++++++-\n admin-frontend/src/types/components.d.ts | 2 +\n admin-frontend/src/utils/nodes.ts | 4 +-\n admin-frontend/src/views/nodes/NodeGroupsView.vue | 314 ++++++++++++-----\n admin-frontend/src/views/nodes/NodeRoutesView.vue | 357 ++++++++++++++-----\n admin-frontend/src/views/nodes/NodesView.vue | 102 +++++-\n .../src/views/system/KnowledgeEditorDialog.vue | 2 +-\n .../src/views/system/SystemKnowledgeView.scss | 69 ++--\n .../src/views/system/SystemKnowledgeView.vue | 391 ++++++++++++++++++++-\n public/assets/admin | 0\n 26 files changed, 1543 insertions(+), 262 deletions(-)\n---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
{
|
{
|
||||||
"updatedAt": "2026-04-24T08:26:29.760Z",
|
"updatedAt": "2026-04-24T09:54:43.768Z",
|
||||||
"source": "manual",
|
"source": "manual",
|
||||||
"originCommand": "generic-r2",
|
"originCommand": "generic-r2",
|
||||||
"reason": "订单管理属于真实后台业务页,需要确认列表结构、筛选条与详情抽屉在代码实现上与目标截图和 Apple 化后台契约一致。",
|
"reason": "节点管理属于截图导向的高密度运营工作台,本轮通过代码结构检查与构建结果确认新增、编辑、排序界面已按目标状态落地。",
|
||||||
"tooling": [
|
"tooling": [
|
||||||
"code inspection",
|
"code-inspection",
|
||||||
"npm run build"
|
"npm run build"
|
||||||
],
|
],
|
||||||
"screensChecked": [
|
"screensChecked": [
|
||||||
"#/subscriptions/orders desktop"
|
"#/nodes desktop"
|
||||||
],
|
],
|
||||||
"statesChecked": [
|
"statesChecked": [
|
||||||
"订单列表默认加载完成态",
|
"节点列表默认加载完成态",
|
||||||
"分配订单抽屉展开态",
|
"新建节点未选择协议态",
|
||||||
"订单详情抽屉展开态"
|
"新建节点 VLess 配置态",
|
||||||
|
"编辑排序对话框态"
|
||||||
],
|
],
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"summary": "已基于代码结构检查与 npm run build 对订单管理首版完成结构化验收:列表页已接入真实 order 接口,筛选条、分配订单抽屉与详情抽屉实现齐全;当前因本地缺少真实后台登录态和浏览器工具,视觉结论以代码级契约核对为准。",
|
"summary": "已确认 NodesView 接入真实新增、编辑、排序入口,NodeEditorDialog 覆盖 11 种协议动态字段,NodeSortDialog 可提交排序 payload,且 admin-frontend 构建通过。",
|
||||||
"findings": [],
|
"findings": [
|
||||||
"recommendations": [
|
"中央大弹窗采用顶部协议选择与白色高密度表单结构,贴近用户截图。",
|
||||||
"下一阶段可补批量操作和礼品卡管理真实页",
|
"VLess、Trojan、VMess 等协议会按安全层与传输层切换不同字段块。",
|
||||||
"若后续具备本地登录态,可补做真实接口环境下的浏览器级视觉回归"
|
"排序流程采用本地草稿加上移/下移,与现有后台排序模式一致。"
|
||||||
],
|
],
|
||||||
|
"recommendations": [],
|
||||||
"fingerprint": {
|
"fingerprint": {
|
||||||
"available": true,
|
"available": true,
|
||||||
"unstaged": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 16 +-\n .helloagents/.ralph-review.json | 24 +-\n .helloagents/.ralph-visual.json | 43 ++--\n .helloagents/CHANGELOG.md | 63 +++++\n .helloagents/INDEX.md | 7 +-\n .helloagents/archive/_index.md | 10 +\n .helloagents/context.md | 26 +-\n .helloagents/modules/_index.md | 1 +\n .helloagents/modules/admin-frontend.md | 29 ++-\n .../2026-04-23T15-07-42-905Z-unknown-t2hj2g.jsonl | 18 --\n .helloagents/sessions/master/default/STATE.md | 21 +-\n admin-frontend/src/api/admin.ts | 229 +++++++++++++++++\n admin-frontend/src/layouts/AdminLayout.vue | 4 +-\n admin-frontend/src/router/index.ts | 22 +-\n admin-frontend/src/types/api.d.ts | 281 ++++++++++++++++++++-\n admin-frontend/src/utils/plans.ts | 9 +\n .../src/views/subscriptions/PlansView.vue | 13 +-\n app/Protocols/Stash.php | 21 ++\n public/assets/admin | 0\n 20 files changed, 746 insertions(+), 95 deletions(-)",
|
"unstaged": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 27 +-\n .helloagents/CHANGELOG.md | 42 +++\n .helloagents/INDEX.md | 2 +-\n .helloagents/archive/_index.md | 5 +\n .helloagents/context.md | 24 +-\n .helloagents/modules/admin-frontend.md | 21 +-\n .../.status.json | 2 +-\n .../tasks.md | 8 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 6 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 1 +\n .helloagents/sessions/master/default/STATE.md | 19 +-\n admin-frontend/src/api/admin.ts | 126 +++++++\n admin-frontend/src/layouts/AdminLayout.vue | 39 +-\n admin-frontend/src/router/index.ts | 6 +\n admin-frontend/src/types/api.d.ts | 216 +++++++++++-\n admin-frontend/src/types/components.d.ts | 2 +\n admin-frontend/src/utils/nodes.ts | 4 +-\n admin-frontend/src/views/nodes/NodeGroupsView.vue | 314 ++++++++++++-----\n admin-frontend/src/views/nodes/NodeRoutesView.vue | 357 ++++++++++++++-----\n admin-frontend/src/views/nodes/NodesView.vue | 102 +++++-\n .../src/views/system/KnowledgeEditorDialog.vue | 2 +-\n .../src/views/system/SystemKnowledgeView.scss | 69 ++--\n .../src/views/system/SystemKnowledgeView.vue | 391 ++++++++++++++++++++-\n public/assets/admin | 0\n 26 files changed, 1541 insertions(+), 260 deletions(-)",
|
||||||
"staged": "",
|
"staged": "",
|
||||||
"combined": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 16 +-\n .helloagents/.ralph-review.json | 24 +-\n .helloagents/.ralph-visual.json | 43 ++--\n .helloagents/CHANGELOG.md | 63 +++++\n .helloagents/INDEX.md | 7 +-\n .helloagents/archive/_index.md | 10 +\n .helloagents/context.md | 26 +-\n .helloagents/modules/_index.md | 1 +\n .helloagents/modules/admin-frontend.md | 29 ++-\n .../2026-04-23T15-07-42-905Z-unknown-t2hj2g.jsonl | 18 --\n .helloagents/sessions/master/default/STATE.md | 21 +-\n admin-frontend/src/api/admin.ts | 229 +++++++++++++++++\n admin-frontend/src/layouts/AdminLayout.vue | 4 +-\n admin-frontend/src/router/index.ts | 22 +-\n admin-frontend/src/types/api.d.ts | 281 ++++++++++++++++++++-\n admin-frontend/src/utils/plans.ts | 9 +\n .../src/views/subscriptions/PlansView.vue | 13 +-\n app/Protocols/Stash.php | 21 ++\n public/assets/admin | 0\n 20 files changed, 746 insertions(+), 95 deletions(-)\n---"
|
"combined": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 27 +-\n .helloagents/CHANGELOG.md | 42 +++\n .helloagents/INDEX.md | 2 +-\n .helloagents/archive/_index.md | 5 +\n .helloagents/context.md | 24 +-\n .helloagents/modules/admin-frontend.md | 21 +-\n .../.status.json | 2 +-\n .../tasks.md | 8 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 6 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 1 +\n .helloagents/sessions/master/default/STATE.md | 19 +-\n admin-frontend/src/api/admin.ts | 126 +++++++\n admin-frontend/src/layouts/AdminLayout.vue | 39 +-\n admin-frontend/src/router/index.ts | 6 +\n admin-frontend/src/types/api.d.ts | 216 +++++++++++-\n admin-frontend/src/types/components.d.ts | 2 +\n admin-frontend/src/utils/nodes.ts | 4 +-\n admin-frontend/src/views/nodes/NodeGroupsView.vue | 314 ++++++++++++-----\n admin-frontend/src/views/nodes/NodeRoutesView.vue | 357 ++++++++++++++-----\n admin-frontend/src/views/nodes/NodesView.vue | 102 +++++-\n .../src/views/system/KnowledgeEditorDialog.vue | 2 +-\n .../src/views/system/SystemKnowledgeView.scss | 69 ++--\n .../src/views/system/SystemKnowledgeView.vue | 391 ++++++++++++++++++++-\n public/assets/admin | 0\n 26 files changed, 1541 insertions(+), 260 deletions(-)\n---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## [0.5.4] - 2026-04-24
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **[admin-frontend]**: 修复仪表盘“节点流量排行 / 用户流量排行”在 `24h` 视图下涨跌始终显示 `0%` 的问题;后端现在会把单日排行改为精确对比昨天整日统计,避免 `record_at=00:00` 的日统计行被秒级窗口错位排除 — by yinjianm
|
||||||
|
- 方案: [202604241925_admin-frontend-dashboard-rank-24h-compare](plan/202604241925_admin-frontend-dashboard-rank-24h-compare/)
|
||||||
|
- 决策: admin-frontend-dashboard-rank-24h-compare#D001(仅修复 24h 与昨天对比逻辑,7天/30天 保持现状)
|
||||||
|
|
||||||
|
## [0.5.3] - 2026-04-24
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **[admin-frontend]**: 完成节点管理真实新增 / 编辑 / 排序工作台,补齐 11 种协议的动态配置弹窗、动态倍率规则编辑、路由 / 权限组联动与排序保存流程,并接入真实 `server/manage/save`、`server/manage/sort`、`server/route/fetch` 后台接口 — by yinjianm
|
||||||
|
- 方案: [202604241718_admin-frontend-node-management](plans/202604241718_admin-frontend-node-management/)
|
||||||
|
- 决策: admin-frontend-node-management#D001(新增与编辑共用中央大弹窗), admin-frontend-node-management#D002(排序沿用本地草稿 + 上移 / 下移), admin-frontend-node-management#D003(协议配置采用通用字段 + 动态协议块)
|
||||||
|
|
||||||
|
## [0.5.2] - 2026-04-24
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **[admin-frontend]**: 开放“礼品卡管理”入口,完整交付模板管理、兑换码管理、使用记录与统计数据四页签工作台,并接入真实 `gift-card/*` 后台接口 — by yinjianm
|
||||||
|
- 方案: [202604241703_admin-frontend-gift-card-management](archive/2026-04/202604241703_admin-frontend-gift-card-management/)
|
||||||
|
- 决策: admin-frontend-gift-card-management#D001(礼品卡管理采用单页四页签导航), admin-frontend-gift-card-management#D002(模板编辑使用分组式大抽屉), admin-frontend-gift-card-management#D003(兑换码导出按当前批次显式执行)
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-04-24
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **[admin-frontend]**: 完成“路由管理”真实工作台,支持路由列表、关键词搜索、新增/编辑弹窗、删除,以及基于节点 `route_ids` 推导的节点引用摘要,并接入真实 `server/route/*` 后台接口 — by yinjianm
|
||||||
|
- 方案: [202604241701_admin-frontend-node-route-management](plan/202604241701_admin-frontend-node-route-management/)
|
||||||
|
- 决策: admin-frontend-node-route-management#D001(列表页贴近用户截图并保留 Apple 化后台节奏), admin-frontend-node-route-management#D002(节点引用摘要先收敛为列表只读信息而不扩展拓扑视图), admin-frontend-node-route-management#D003(动作值仅在 dns/proxy 时显示独立输入)
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-04-24
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **[admin-frontend]**: 将 `#/node-groups` 从占位页升级为真实权限组管理工作台,支持搜索、新增/编辑中央弹窗、删除确认,并补齐到 `#/nodes` 的权限组筛选联动入口 — by yinjianm
|
||||||
|
- 方案: [202604241659_admin-frontend-node-group-management](archive/2026-04/202604241659_admin-frontend-node-group-management/)
|
||||||
|
- 决策: admin-frontend-node-group-management#D001(权限组页采用截图导向的轻量工作台), admin-frontend-node-group-management#D002(新增与编辑复用同一中央弹窗), admin-frontend-node-group-management#D003(节点数量列承担跳转到节点筛选的联动入口)
|
||||||
|
|
||||||
|
## [0.4.8] - 2026-04-24
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **[admin-frontend]**: 修复 `#/system/knowledge` 仍回退到结构化占位页的问题,补齐真实知识库管理列表挂载、详情编辑加载与最新构建产物刷新 — by yinjianm
|
||||||
|
- 方案: [202604241610_admin-frontend-knowledge-management](plan/202604241610_admin-frontend-knowledge-management/)
|
||||||
|
- 决策: admin-frontend-knowledge-management#D001(编辑器采用轻量 Markdown 方案), admin-frontend-knowledge-management#D002(列表页采用真实表格与中央对话框), admin-frontend-knowledge-management#D003(排序采用本地草稿编辑后统一提交)
|
||||||
|
|
||||||
|
## [0.4.7] - 2026-04-24
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **[admin-frontend]**: 修复管理端侧边栏在低窗口高度下会裁切底部菜单的问题;现在顶部品牌区保持固定,菜单区可独立滚动访问“礼品卡管理”“系统管理”“支付配置”“知识库管理”等底部入口 — by yinjianm
|
||||||
|
- 方案: [202604241655_admin-frontend-sidebar-height-overflow](archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/)
|
||||||
|
- 决策: admin-frontend-sidebar-height-overflow#D001(固定品牌区 + 独立滚动菜单区)
|
||||||
|
|
||||||
## [0.4.6] - 2026-04-24
|
## [0.4.6] - 2026-04-24
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ active_package: 无
|
|||||||
|
|
||||||
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
||||||
- 当前重点模块: `admin-frontend`、`subscription-protocols`
|
- 当前重点模块: `admin-frontend`、`subscription-protocols`
|
||||||
- 最新归档: `202604241620_admin-frontend-order-management`
|
- 最新归档: `202604241703_admin-frontend-gift-card-management`
|
||||||
|
|
||||||
## 活跃模块
|
## 活跃模块
|
||||||
|
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"completed": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"total": 3,
|
||||||
|
"done": 3,
|
||||||
|
"percent": 100,
|
||||||
|
"current": "全部任务已完成,等待归档方案包",
|
||||||
|
"updated_at": "2026-04-24 17:07:00"
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
# 变更提案: admin-frontend-sidebar-height-overflow
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 缺陷修复
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已确认
|
||||||
|
创建: 2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
`admin-frontend` 新增系统管理与订阅管理分组后,左侧导航项明显增多。当前 `AdminLayout.vue` 的侧边栏仍采用“Logo + ElMenu 直接铺满”的结构,而 `body` 又被全局设置为 `overflow: hidden`。在用户截图所示的小窗口高度下,底部菜单会被直接裁切,导致“礼品卡管理”“系统管理”“支付配置”等入口无法完整访问。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 修复管理端侧边栏在低视口高度下显示不全的问题。
|
||||||
|
- 按用户已确认的方案,保留顶部 Logo/品牌区固定,仅让菜单区独立纵向滚动。
|
||||||
|
- 保持现有 Apple 风格后台的导航层级、折叠行为和移动端体验不回退。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
范围约束: 仅处理 admin 侧边栏在低高度下的可访问性与滚动行为,不扩展菜单信息架构
|
||||||
|
技术约束: 继续使用 Vue3 + TypeScript + Element Plus,不新增依赖
|
||||||
|
兼容性约束: 需兼容桌面展开态、折叠态以及现有移动端 fixed aside 行为
|
||||||
|
视觉约束: 延续 apple/DESIGN.md 与 .helloagents/DESIGN.md 的纯白侧栏 + 单一蓝色激活态体系
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 在窗口高度不足时,侧边栏底部菜单项仍可通过纵向滚动访问完整。
|
||||||
|
- [ ] 顶部 Logo/品牌区保持固定,不跟随菜单滚动一起消失。
|
||||||
|
- [ ] 折叠态与移动端侧边栏不出现新的遮挡、抖动或布局错位。
|
||||||
|
- [ ] `admin-frontend` 执行 `npm run build` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
1. 在 `AdminLayout.vue` 中把侧边栏拆成“固定头部品牌区 + 独立滚动的菜单容器”两层结构,而不是直接让 `ElMenu` 占满整个 `ElAside`。
|
||||||
|
2. 为侧边栏与菜单滚动容器补齐 `min-height: 0`、`overflow-y: auto`、底部留白和滚动条细节,解决 flex 子项在固定高度容器中无法正确收缩的问题。
|
||||||
|
3. 保持现有 `ElMenu`、`ElSubMenu` 和折叠逻辑不变,确保这次修复是针对根因的最小改动,而不是重写导航实现。
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
```yaml
|
||||||
|
涉及模块:
|
||||||
|
- admin-frontend: 更新主布局侧边栏结构与样式,修复低高度下菜单裁切
|
||||||
|
- .helloagents/modules/admin-frontend.md: 记录侧边栏低高度滚动规则
|
||||||
|
预计变更文件: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| Element Plus 菜单在滚动容器中出现高度塌陷或双滚动条 | 中 | 采用独立滚动容器并显式补齐 `min-height: 0` 与内边距,避免只在 `ElMenu` 上追加单点样式 |
|
||||||
|
| 移动端 fixed aside 与桌面侧栏样式相互影响 | 中 | 仅在当前侧边栏作用域内增加滚动容器样式,保留现有移动端定位策略 |
|
||||||
|
| 构建会刷新 `public/assets/admin` 构建产物 | 低 | 本轮仅完成本地构建验证,不自动代做子模块发布 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心场景
|
||||||
|
|
||||||
|
### 场景: 低高度窗口中的侧边栏导航
|
||||||
|
**模块**: admin-frontend
|
||||||
|
**条件**: 浏览器窗口高度不足以同时容纳品牌区与全部菜单项
|
||||||
|
**行为**: 用户在左侧导航区域滚动菜单
|
||||||
|
**结果**: 顶部品牌区固定可见,菜单项可完整访问,激活态与分组结构保持正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 技术决策
|
||||||
|
|
||||||
|
### admin-frontend-sidebar-height-overflow#D001: 侧边栏采用“固定品牌区 + 独立滚动菜单区”
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户明确选择“顶部 Logo/品牌区固定,仅菜单区域独立纵向滚动”的处理方式。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 固定品牌区,菜单区独立滚动 | 保留品牌识别和导航定位,改动集中、体验最稳定 | 需要微调侧栏结构和滚动容器样式 |
|
||||||
|
| B: 整个侧边栏整体滚动 | 实现简单 | 品牌区会被滚出视口,低高度下辨识度更差 |
|
||||||
|
| C: 低高度自动折叠成图标栏 | 可压缩空间 | 会改变现有导航行为,且不是用户选定方案 |
|
||||||
|
**决策**: 选择方案 A
|
||||||
|
**理由**: 最符合用户已确认交互,也与当前 Apple 风格后台“固定品牌区 + 稳定导航层级”的设计基线一致。
|
||||||
|
**影响**: 影响 `AdminLayout.vue` 侧边栏结构与样式,不涉及 API、路由或业务数据流。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 成果设计
|
||||||
|
|
||||||
|
### 设计方向
|
||||||
|
- **美学基调**: 克制运营后台 —— 保留纯白侧栏、细边框、轻阴影和单一蓝色激活态,让修复更像“增强可用性”而非“换一套皮肤”
|
||||||
|
- **记忆点**: 窗口再矮,顶部品牌区依旧稳定悬停,菜单像独立轨道一样顺滑滚动
|
||||||
|
- **参考**: 现有 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md` 的 Apple 风格后台规范
|
||||||
|
|
||||||
|
### 视觉要素
|
||||||
|
- **配色**: 保持现有 `#ffffff` 侧栏底色、`#0071e3` 激活色和中性文字层级,不引入新强调色
|
||||||
|
- **字体**: 延续全局系统字体栈 `--xboard-font-sans`,不新增字体
|
||||||
|
- **布局**: 侧栏继续分为顶部品牌区与下方菜单区,但改为明确的上下分层,菜单区获得独立滚动上下文
|
||||||
|
- **动效**: 保留当前宽度折叠过渡;滚动本身不增加额外装饰动画
|
||||||
|
- **氛围**: 维持轻薄、低噪音的 Apple 化后台质感,仅补齐滚动可达性
|
||||||
|
|
||||||
|
### 技术约束
|
||||||
|
- **可访问性**: 键盘聚焦与当前菜单激活态不可被滚动修复破坏
|
||||||
|
- **响应式**: 桌面低高度、桌面折叠态和移动端 fixed aside 都需保持可用
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# 任务清单: admin-frontend-sidebar-height-overflow
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-24 17:05
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: admin-frontend-sidebar-height-overflow
|
||||||
|
@created: 2026-04-24
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 3 | 0 | 0 | 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
- [√] 1. 审查 `admin-frontend/src/layouts/AdminLayout.vue` 当前侧边栏结构与低高度裁切根因,冻结“固定品牌区 + 菜单独立滚动”实现边界 | depends_on: []
|
||||||
|
- [√] 2. 在 `admin-frontend/src/layouts/AdminLayout.vue` 中调整侧边栏结构与样式,修复低高度下菜单显示不全问题 | depends_on: [1]
|
||||||
|
- [√] 3. 运行 `admin-frontend` 构建验证,并同步 `.helloagents/modules/admin-frontend.md` 与 `CHANGELOG.md` 记录本轮修复 | depends_on: [2]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-24 16:55 | 方案包初始化 | completed | 已确认采用“顶部品牌区固定,菜单区域独立纵向滚动”的修复策略 |
|
||||||
|
| 2026-04-24 16:58 | 根因分析 | completed | 已确认裁切来自 `ElAside` 固定高度 + `ElMenu` 缺少独立滚动上下文,菜单区可滚动高度不足 |
|
||||||
|
| 2026-04-24 17:02 | 布局修复 | completed | 已将侧边栏拆为固定品牌区与独立滚动菜单区,并补齐 `min-height: 0` / `overflow-y: auto` 样式 |
|
||||||
|
| 2026-04-24 17:07 | 验证与知识同步 | completed | `npm run build` 通过;Playwright 在 1200x540 视口下确认菜单区 `scrollHeight 1020 > clientHeight 442`,滚动到底后可见系统管理与知识库管理入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- 本轮只修复侧边栏低高度可访问性,不调整菜单信息架构与图标分组。
|
||||||
|
- 构建验证会刷新 `public/assets/admin` 构建产物,本轮仅完成本地实现与验证,不自动代做子模块发布。
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"completed": 4,
|
||||||
|
"failed": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"total": 4,
|
||||||
|
"done": 4,
|
||||||
|
"percent": 100,
|
||||||
|
"current": "权限组管理真实页、节点页联动与构建验证已完成",
|
||||||
|
"updated_at": "2026-04-24 17:10:00"
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
# 变更提案: admin-frontend-node-group-management
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 功能增强
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已完成
|
||||||
|
创建: 2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
`admin-frontend` 的 `#/node-groups` 目前仍是结构化占位页,而用户已经提供了目标截图,明确要求继续完成“权限组管理”真实工作台。现有 Laravel 后端已经开放 `server/group/fetch`、`server/group/save` 与 `server/group/drop`,节点管理页也已接入权限组筛选,但缺少从权限组页进入节点筛选的维护闭环。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 将 `#/node-groups` 从占位页升级为真实权限组管理页面,支持列表、搜索、添加、编辑、删除。
|
||||||
|
- 列表中直接展示权限组 ID、组名称、用户数量、节点数量,并给出符合截图习惯的操作位。
|
||||||
|
- 补齐与 `#/nodes` 的联动:从权限组页可以一键带筛选跳转到节点页,节点页也能快速回到权限组维护入口。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
范围约束: 仅实现 admin-frontend 的权限组前端工作台与节点页联动,不改 Laravel 后端接口行为
|
||||||
|
技术约束: 继续使用 Vue3 + TypeScript + Element Plus + 现有 axios/adminClient 栈,不新增第三方状态或表格依赖
|
||||||
|
视觉约束: 以用户截图为直接参考,保留 Apple 化后台的克制留白、系统字体与轻量表格节奏,不套用订阅页黑色 Hero
|
||||||
|
业务约束: 后端真相源固定为 server/group/fetch、server/group/save、server/group/drop;删除失败原因以前端透传后端文案为准
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [√] `#/node-groups` 可以展示真实权限组列表,并支持关键字搜索与分页浏览。
|
||||||
|
- [√] 页面支持新增、编辑、删除权限组;新增/编辑使用中央弹窗,删除前有明确确认。
|
||||||
|
- [√] 节点数量支持跳转到 `#/nodes` 并自动带入对应权限组筛选;节点页提供回到权限组维护入口的联动按钮。
|
||||||
|
- [√] `admin-frontend` 执行 `npm run build` 通过,产物可继续输出到 `public/assets/admin`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
1. 在 `src/types/api.d.ts` 与 `src/api/admin.ts` 中补齐权限组保存请求类型与 `save/delete` 封装。
|
||||||
|
2. 新增 `src/utils/nodeGroups.ts`,统一处理权限组计数归一化、本地搜索和摘要计算,避免视图层堆积业务细节。
|
||||||
|
3. 重写 `NodeGroupsView.vue`,采用“页头说明 + 紧凑工具条 + 白色表格工作台 + 中央编辑弹窗”的结构贴近截图。
|
||||||
|
4. 新增 `NodeGroupEditorDialog.vue` 处理新增/编辑流程;节点数量使用按钮式链接跳转到 `/nodes?group={id}`。
|
||||||
|
5. 轻量补齐 `NodesView.vue`:识别路由查询中的权限组筛选,并提供“管理权限组”入口形成维护闭环。
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
```yaml
|
||||||
|
涉及模块:
|
||||||
|
- admin-frontend: 权限组管理真实页、节点页筛选联动、API/类型/工具层补齐
|
||||||
|
- .helloagents: 方案包、模块文档、CHANGELOG 与状态证据同步
|
||||||
|
预计变更文件: 8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 后端删除接口因用户/节点/订阅占用返回失败 | 中 | 前端保持后端错误透传,不自行猜测失败原因 |
|
||||||
|
| 节点页联动仅靠前端路由查询,若权限组已删除会产生空筛选 | 低 | 页面加载后校验 query 对应分组是否存在,不存在则自动回退为“全部权限组” |
|
||||||
|
| 新页面过度复用黑色 Hero 会与截图不一致 | 中 | 以截图为最高优先级,改为轻量标题区 + 高密度表格,不强行套用订阅页首屏模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术设计(可选)
|
||||||
|
|
||||||
|
### API设计
|
||||||
|
#### GET /server/group/fetch
|
||||||
|
- **请求**: 无
|
||||||
|
- **响应**: `AdminServerGroupItem[]`,包含 `id`、`name`、`users_count`、`server_count`
|
||||||
|
|
||||||
|
#### POST /server/group/save
|
||||||
|
- **请求**: `{ id?: number, name: string }`
|
||||||
|
- **响应**: `boolean`
|
||||||
|
|
||||||
|
#### POST /server/group/drop
|
||||||
|
- **请求**: `{ id: number }`
|
||||||
|
- **响应**: `boolean`
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | number | 权限组 ID |
|
||||||
|
| name | string | 权限组名称 |
|
||||||
|
| users_count | number | 绑定用户数量 |
|
||||||
|
| server_count | number | 绑定节点数量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心场景
|
||||||
|
|
||||||
|
### 场景: 运营新增权限组
|
||||||
|
**模块**: admin-frontend
|
||||||
|
**条件**: 管理员进入 `#/node-groups`
|
||||||
|
**行为**: 点击“添加权限组”,填写组名称并提交
|
||||||
|
**结果**: 列表刷新并出现新的权限组记录
|
||||||
|
|
||||||
|
### 场景: 运营从权限组进入节点排查
|
||||||
|
**模块**: admin-frontend
|
||||||
|
**条件**: 某权限组已存在关联节点
|
||||||
|
**行为**: 在权限组列表点击“节点数量”跳转
|
||||||
|
**结果**: `#/nodes` 自动带入对应权限组筛选,仅展示该组关联节点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
|
||||||
|
### admin-frontend-node-group-management#D001: 权限组页改为截图导向的轻量工作台,而不是延续黑色 Hero
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户提供的参考图就是目标界面,重点是轻量标题区、工具条和高密度表格。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 延续节点/订阅页的黑色 Hero | 与部分已有页面一致 | 与当前截图不符,页面信息密度被拉低 |
|
||||||
|
| B: 采用截图式轻量工作台 | 更贴近用户目标,便于高频运营维护 | 与部分页面的 Hero 结构不完全统一 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 本轮任务已有明确视觉参考,参考优先级高于通用页面套路。
|
||||||
|
**影响**: `NodeGroupsView.vue` 的首屏结构与样式策略
|
||||||
|
|
||||||
|
### admin-frontend-node-group-management#D002: 新增与编辑复用同一个中央弹窗
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 后端保存接口统一为 `server/group/save`,截图也展示了中央编辑弹窗。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 新增/编辑拆成两个独立组件 | 职责更独立 | 代码重复,交互不连续 |
|
||||||
|
| B: 统一弹窗组件按模式切换 | 与后端接口一致,界面行为稳定 | 组件需处理回填逻辑 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 能最小化重复代码,同时贴合截图中的工作流。
|
||||||
|
**影响**: `NodeGroupEditorDialog.vue` 的设计与表单逻辑
|
||||||
|
|
||||||
|
### admin-frontend-node-group-management#D003: 节点数量列承担“跳转到节点筛选”联动入口
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户选择了“完整闭环”,不仅要做权限组页,还要补齐节点页联动。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 只做权限组页 CRUD | 实现最小 | 无法形成节点维护闭环 |
|
||||||
|
| B: 在节点数量列加入带筛选跳转,并让节点页识别 query | 联动直接、改动范围可控 | 需要额外处理路由筛选同步 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 这是当前范围内成本最低、用户价值最高的联动方式。
|
||||||
|
**影响**: `NodeGroupsView.vue`、`NodesView.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 成果设计
|
||||||
|
|
||||||
|
### 设计方向
|
||||||
|
- **美学基调**: 精密运营台账风——保留 Apple 化后台的轻薄分区与系统字体,但将视觉重心收敛到“标题、工具条、表格、弹窗”四个层级,避免营销感首屏
|
||||||
|
- **记忆点**: 大面积留白中的细线表格与圆角操作按钮,让“节点数量可跳转”的数据列成为页面最有识别度的互动点
|
||||||
|
- **参考**: 用户提供的权限组管理截图与编辑弹窗截图
|
||||||
|
|
||||||
|
### 视觉要素
|
||||||
|
- **配色**: 以 `#ffffff` / `#f5f5f7` 为背景层,正文使用 `--xboard-text-strong` 与 `--xboard-text-secondary`,交互强调保持 `#0071e3`
|
||||||
|
- **字体**: 延续项目现有系统字体栈,不额外引入新字体;通过更克制的字号梯度和字重对比强化“台账式”阅读效率
|
||||||
|
- **布局**: 顶部使用轻标题区,正文直接进入紧凑工具条与全宽表格;编辑器保持中央对话框,不用抽屉
|
||||||
|
- **动效**: 仅保留按钮 hover、表格行内操作反馈、分页与弹窗开合的默认 Element Plus 过渡
|
||||||
|
- **氛围**: 依靠细边框、极轻阴影、圆角输入框与足够留白构成“干净但不空”的后台质感
|
||||||
|
|
||||||
|
### 技术约束
|
||||||
|
- **可访问性**: 搜索框、主按钮、编辑/删除操作与节点跳转入口都要保留可见焦点;危险删除继续使用明确确认文案
|
||||||
|
- **响应式**: 桌面优先显示完整表格;窄屏下工具条允许换行,底部分页与统计信息可纵向堆叠
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# 任务清单: admin-frontend-node-group-management
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-24 17:11
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: admin-frontend-node-group-management
|
||||||
|
@created: 2026-04-24
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 4 | 0 | 0 | 4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
- [√] 1. 梳理 `server/group/*` 后端接口、现有 `admin-frontend` 设计契约与节点页联动边界 | depends_on: []
|
||||||
|
- [√] 2. 补齐权限组 API / 类型 / 工具层,并实现新增 / 编辑弹窗 | depends_on: [1]
|
||||||
|
- [√] 3. 重写 `NodeGroupsView` 真实工作台,并补齐 `NodesView` 的权限组筛选联动入口 | depends_on: [1, 2]
|
||||||
|
- [√] 4. 运行 `admin-frontend` 构建验证,更新 `.helloagents` 文档与交付证据 | depends_on: [2, 3]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-24 17:00 | 方案包初始化 | completed | 用户已选择“完整闭环”,本轮范围包含权限组真实页与节点页联动 |
|
||||||
|
| 2026-04-24 17:04 | 页面实现 | completed | 已补齐权限组 API、工具层、中央编辑弹窗与真实列表工作台 |
|
||||||
|
| 2026-04-24 17:07 | 联动实现 | completed | 节点数量列现可跳转 `#/nodes?group={id}`,节点页新增“管理权限组”入口 |
|
||||||
|
| 2026-04-24 17:10 | 构建与文档同步 | completed | `npm run build` 通过,并已更新 `.helloagents` 文档与变更日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- 当前根仓存在其他未归档方案包与历史改动,本轮仅增量修改权限组管理及节点页联动相关文件。
|
||||||
|
- `public/assets/admin` 为前端产物子模块;构建通过后需要同时复核根仓与子模块状态。
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-04-24T09:09:46.384Z",
|
||||||
|
"version": 1,
|
||||||
|
"source": "manual",
|
||||||
|
"originCommand": "generic-r2",
|
||||||
|
"verifyMode": "test-first",
|
||||||
|
"reviewerFocus": [
|
||||||
|
"礼品卡管理整体结构是否贴近用户提供的四页签后台截图",
|
||||||
|
"模板抽屉字段分组是否与截图和现有后端 JSON 结构一致"
|
||||||
|
],
|
||||||
|
"testerFocus": [
|
||||||
|
"礼品卡管理是否真实连接 gift-card/templates、codes、usages、statistics、types 接口",
|
||||||
|
"模板新增编辑是否能正确序列化 conditions、rewards、limits、special_config",
|
||||||
|
"兑换码管理是否支持生成、导出、复制、启停、编辑和删除主链路"
|
||||||
|
],
|
||||||
|
"ui": {
|
||||||
|
"required": true,
|
||||||
|
"designContract": true,
|
||||||
|
"sourcePriority": [
|
||||||
|
"requirements.md",
|
||||||
|
".helloagents/DESIGN.md",
|
||||||
|
"hello-ui"
|
||||||
|
],
|
||||||
|
"styleAdvisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": []
|
||||||
|
},
|
||||||
|
"visualValidation": {
|
||||||
|
"required": true,
|
||||||
|
"reason": "礼品卡管理属于整页新增的真实后台业务页,需要确认四页签导航、模板表格、模板抽屉和统计卡片在代码结构上与目标截图及 Apple 化后台契约一致。",
|
||||||
|
"screens": [
|
||||||
|
"#/subscriptions/gift-cards desktop"
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
"模板管理默认加载完成态",
|
||||||
|
"模板新增抽屉展开态",
|
||||||
|
"兑换码管理列表态",
|
||||||
|
"统计数据总览态"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": [],
|
||||||
|
"preferredSources": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# admin-frontend 礼品卡管理首版交付 — 实施规划
|
||||||
|
|
||||||
|
## 目标与范围
|
||||||
|
- 在现有订阅管理分组中补齐礼品卡管理真实页,替换原先的禁用入口。
|
||||||
|
- 页面聚焦“礼品卡运营工作台”主链路:建模板、生成兑换码、查看使用记录、追踪统计数据。
|
||||||
|
|
||||||
|
## 架构与实现策略
|
||||||
|
- 在 `AdminLayout` 中开放 `/subscriptions/gift-cards` 导航入口,并在路由中新增对应页面。
|
||||||
|
- 新增 `GiftCardsView` 作为整页工作台,整体结构参考用户截图:
|
||||||
|
- 顶部标题与说明
|
||||||
|
- 四段式分段导航(模板管理 / 兑换码管理 / 使用记录 / 统计数据)
|
||||||
|
- 每个页签独立的筛选条、表格/卡片内容与操作按钮
|
||||||
|
- 新增两个业务弹层组件:
|
||||||
|
- `GiftCardTemplateDrawer.vue`:负责模板新增与编辑
|
||||||
|
- `GiftCardCodeBatchDialog.vue`:负责批量生成兑换码
|
||||||
|
- 在 `src/utils/giftCards.ts` 中集中处理:
|
||||||
|
- 类型/状态映射
|
||||||
|
- 模板表单序列化与反序列化
|
||||||
|
- 金额(元)/流量(GB)/倍率/日期等展示与提交格式转换
|
||||||
|
- 本地搜索筛选与统计卡片整理
|
||||||
|
- API 层在 `src/api/admin.ts` 中新增礼品卡接口封装;类型定义统一补到 `src/types/api.d.ts`。
|
||||||
|
|
||||||
|
## 完成定义
|
||||||
|
- 侧边栏中的“礼品卡管理”不再是禁用入口,能正常进入 `#/subscriptions/gift-cards`。
|
||||||
|
- 模板管理页可真实连接模板列表与 CRUD 接口,支持搜索、筛选、启停与删除。
|
||||||
|
- 模板抽屉可完整编辑截图展示的主要字段分组,并正确序列化为后端 `conditions / rewards / limits / special_config` 结构。
|
||||||
|
- 兑换码页可真实连接兑换码列表,支持批量生成、复制、启停、导出当前批次、编辑有效期/次数与删除。
|
||||||
|
- 使用记录与统计数据页可真实连接后端数据,不使用硬编码假数据。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
- `admin-frontend/src/layouts/AdminLayout.vue`
|
||||||
|
- `admin-frontend/src/router/index.ts`
|
||||||
|
- `admin-frontend/src/api/admin.ts`
|
||||||
|
- `admin-frontend/src/types/api.d.ts`
|
||||||
|
- `admin-frontend/src/utils/giftCards.ts`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardsView.vue`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardsView.scss`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardTemplateDrawer.vue`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardCodeBatchDialog.vue`
|
||||||
|
|
||||||
|
## UI / 设计约束
|
||||||
|
- 页面采用“标题说明 + 轻量分段页签 + 白色工作台”的 Apple 化运营后台节奏,不额外叠加夸张 hero 或营销化视觉。
|
||||||
|
- 四个页签保持统一信息架构与表格密度,让用户能快速在模板、兑换码、记录、统计之间切换。
|
||||||
|
- 模板抽屉使用分组 section 和双列表单布局,对齐截图中的信息分区;在窄屏下自动堆叠为单列。
|
||||||
|
- 状态标签、奖励摘要、统计卡片继续沿用单一蓝色强调和语义色状态胶囊,不引入新配色体系。
|
||||||
|
|
||||||
|
## 风险与验证
|
||||||
|
- 风险 1:模板表单字段较多,若直接散落在组件内易导致提交结构和展示结构不一致,因此统一收敛到 `src/utils/giftCards.ts`。
|
||||||
|
- 风险 2:兑换码列表接口不支持关键词搜索,需要前端在当前拉取结果上做本地搜索,并明确这是列表内过滤。
|
||||||
|
- 风险 3:本地环境缺少真实后台登录态时,只能做结构与构建验证,不能替代完整联调。
|
||||||
|
- 验证方式:
|
||||||
|
- `npm run build`
|
||||||
|
- 代码级结构自检 `#/subscriptions/gift-cards`
|
||||||
|
- 结构化视觉验收记录(无浏览器工具时以 code inspection 说明边界)
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
- [2026-04-24] 礼品卡管理采用单页四段式导航,而不是四个独立路由,以贴近用户截图中的运营切换路径。
|
||||||
|
- [2026-04-24] 模板抽屉使用分组式大表单,不把复杂字段塞进居中弹窗,以保证高密度配置仍可读。
|
||||||
|
- [2026-04-24] 兑换码导出先按“当前选中批次”提供显式出口,不额外扩展复杂多选批量导出流程。
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# admin-frontend 礼品卡管理首版交付 — 需求
|
||||||
|
|
||||||
|
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
- 在 `admin-frontend` 中开放“订阅管理 / 礼品卡管理”入口,不再保留禁用态。
|
||||||
|
- 参考用户提供的 5 张截图,交付礼品卡管理真实工作台,覆盖模板管理、兑换码管理、使用记录与统计数据四个页签。
|
||||||
|
- 保持 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md` 定义的 Apple 化后台视觉语言,同时优先贴近截图中的高密度运营视图与轻量分段导航。
|
||||||
|
|
||||||
|
## 功能边界
|
||||||
|
- 必须实现 `#/subscriptions/gift-cards` 真实页面。
|
||||||
|
- 页面必须包含:
|
||||||
|
- 顶部标题说明与四段式页签导航
|
||||||
|
- 模板管理列表、搜索、类型/状态筛选、显隐切换、新增/编辑、删除
|
||||||
|
- 模板新增/编辑大表单,覆盖基础配置、奖励内容、使用条件、使用限制、特殊配置、显示效果
|
||||||
|
- 兑换码管理列表、模板/状态筛选、复制、启停、编辑、删除、批量生成、批次导出
|
||||||
|
- 使用记录列表与用户邮箱搜索
|
||||||
|
- 统计数据总览,至少展示模板总数、活跃模板数、兑换码总数、已使用兑换码
|
||||||
|
- 必须接入现有 Laravel 管理接口:
|
||||||
|
- `GET /gift-card/templates`
|
||||||
|
- `POST /gift-card/create-template`
|
||||||
|
- `POST /gift-card/update-template`
|
||||||
|
- `POST /gift-card/delete-template`
|
||||||
|
- `POST /gift-card/generate-codes`
|
||||||
|
- `GET /gift-card/codes`
|
||||||
|
- `POST /gift-card/toggle-code`
|
||||||
|
- `GET /gift-card/export-codes`
|
||||||
|
- `POST /gift-card/update-code`
|
||||||
|
- `POST /gift-card/delete-code`
|
||||||
|
- `GET /gift-card/usages`
|
||||||
|
- `GET /gift-card/statistics`
|
||||||
|
- `GET /gift-card/types`
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
- 本轮不改造 Laravel 礼品卡后端逻辑、校验规则或数据库结构。
|
||||||
|
- 本轮不实现用户端礼品卡兑换体验。
|
||||||
|
- 本轮不引入复杂图表库,只使用现有栈完成统计展示。
|
||||||
|
|
||||||
|
## 技术约束
|
||||||
|
- 技术栈固定为 `Vue 3 + TypeScript + Vite + Element Plus`。
|
||||||
|
- 后端真相源以仓库内 `App\Http\Controllers\V2\Admin\GiftCardController`、`GiftCardTemplate`、`GiftCardCode` 与 `GiftCardUsage` 为准。
|
||||||
|
- 构建验证使用 `admin-frontend/package.json` 中已有 `npm run build`。
|
||||||
|
- 构建产物继续输出到 `public/assets/admin` 子模块。
|
||||||
|
|
||||||
|
## 质量要求
|
||||||
|
- 礼品卡页面需要对齐截图中的运营后台结构:白色工作台、轻量页签、克制筛选条、高密度表格。
|
||||||
|
- 表单字段需要覆盖加载、保存、取消、校验失败与成功提示等基本状态。
|
||||||
|
- 金额、流量、时间与倍率展示必须按人类可读方式格式化,不直接暴露原始后端数值。
|
||||||
|
- 最终至少完成一次构建验证,并留下结构化视觉验收与交付证据。
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# admin-frontend 礼品卡管理首版交付 — 任务分解
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
- [x] 任务1:补齐礼品卡管理方案与合同产物(涉及文件:`.helloagents/plans/202604241703_admin-frontend-gift-card-management/*`;完成标准:存在需求、方案、任务与合同文件;验证方式:文件检查)
|
||||||
|
- [x] 任务2:开放礼品卡导航与路由入口(涉及文件:`admin-frontend/src/layouts/AdminLayout.vue`、`admin-frontend/src/router/index.ts`;完成标准:侧边栏可进入 `#/subscriptions/gift-cards`;验证方式:`npm run build`)
|
||||||
|
- [x] 任务3:补齐礼品卡 API、类型与工具层(涉及文件:`admin-frontend/src/api/admin.ts`、`admin-frontend/src/types/api.d.ts`、`admin-frontend/src/utils/giftCards.ts`;完成标准:前端可消费 `gift-card/*` 接口并统一完成字段映射;验证方式:`npm run build`)
|
||||||
|
- [x] 任务4:实现礼品卡管理主页面(涉及文件:`admin-frontend/src/views/subscriptions/GiftCardsView.vue`、`admin-frontend/src/views/subscriptions/GiftCardsView.scss`;完成标准:四个页签支持真实数据展示、搜索筛选、表格/统计渲染与关键操作入口;验证方式:`npm run build`)
|
||||||
|
- [x] 任务5:实现模板抽屉与兑换码生成弹层(涉及文件:`admin-frontend/src/views/subscriptions/GiftCardTemplateDrawer.vue`、`admin-frontend/src/views/subscriptions/GiftCardCodeBatchDialog.vue`;完成标准:支持模板新增/编辑与兑换码批量生成;验证方式:`npm run build`)
|
||||||
|
- [x] 任务6:完成验证与知识库同步(涉及文件:`.helloagents/CHANGELOG.md`、`.helloagents/context.md`、`.helloagents/modules/admin-frontend.md`、`.helloagents/.ralph-visual.json`、`.helloagents/.ralph-closeout.json`;完成标准:构建通过、知识库更新、交付证据写入;验证方式:命令输出 + 证据文件)
|
||||||
|
|
||||||
|
## 进度
|
||||||
|
- [x] 已确认礼品卡管理按完整四页签首版推进。
|
||||||
|
- [x] 已完成礼品卡管理真实页面、模板抽屉、兑换码生成弹层与后端接口接入。
|
||||||
|
- [x] 已完成构建验证,待输出最终交付摘要。
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||||
|--------|------|------|---------|------|------|
|
|--------|------|------|---------|------|------|
|
||||||
|
| 202604241703 | admin-frontend-gift-card-management | implementation | admin-frontend | admin-frontend-gift-card-management#D001,#D002,#D003 | ✅完成 |
|
||||||
|
| 202604241659 | admin-frontend-node-group-management | implementation | admin-frontend | admin-frontend-node-group-management#D001,#D002,#D003 | ✅完成 |
|
||||||
|
| 202604241655 | admin-frontend-sidebar-height-overflow | - | - | - | ✅完成 |
|
||||||
| 202604241620 | admin-frontend-order-management | implementation | admin-frontend | admin-frontend-order-management#D001,#D002,#D003 | ✅完成 |
|
| 202604241620 | admin-frontend-order-management | implementation | admin-frontend | admin-frontend-order-management#D001,#D002,#D003 | ✅完成 |
|
||||||
| 202604241558 | admin-frontend-payment-management | implementation | admin-frontend | admin-frontend-payment-management#D001,#D002,#D003 | ✅完成 |
|
| 202604241558 | admin-frontend-payment-management | implementation | admin-frontend | admin-frontend-payment-management#D001,#D002,#D003 | ✅完成 |
|
||||||
| 202604241553 | admin-frontend-plugin-management | implementation | admin-frontend | admin-frontend-plugin-management#D001,#D002,#D003 | ✅完成 |
|
| 202604241553 | admin-frontend-plugin-management | implementation | admin-frontend | admin-frontend-plugin-management#D001,#D002,#D003 | ✅完成 |
|
||||||
@@ -29,6 +32,8 @@
|
|||||||
## 按月归档
|
## 按月归档
|
||||||
|
|
||||||
### 2026-04
|
### 2026-04
|
||||||
|
- [202604241703_admin-frontend-gift-card-management](./2026-04/202604241703_admin-frontend-gift-card-management/) - 开放“礼品卡管理”入口,交付模板管理、兑换码管理、使用记录与统计数据四页签工作台,并接入真实 gift-card 接口
|
||||||
|
- [202604241659_admin-frontend-node-group-management](./2026-04/202604241659_admin-frontend-node-group-management/) - 将 `#/node-groups` 从占位页升级为真实权限组管理工作台,并补齐到 `#/nodes` 的权限组筛选联动
|
||||||
- [202604241620_admin-frontend-order-management](./2026-04/202604241620_admin-frontend-order-management/) - 开放“订单管理”入口,交付真实订单列表、筛选、分配订单、详情抽屉、手动支付与佣金状态维护
|
- [202604241620_admin-frontend-order-management](./2026-04/202604241620_admin-frontend-order-management/) - 开放“订单管理”入口,交付真实订单列表、筛选、分配订单、详情抽屉、手动支付与佣金状态维护
|
||||||
- [202604241558_admin-frontend-payment-management](./2026-04/202604241558_admin-frontend-payment-management/) - 将 `#/system/payments` 从占位页升级为真实支付配置工作台,接入支付方式列表、动态配置抽屉、启停、删除与排序
|
- [202604241558_admin-frontend-payment-management](./2026-04/202604241558_admin-frontend-payment-management/) - 将 `#/system/payments` 从占位页升级为真实支付配置工作台,接入支付方式列表、动态配置抽屉、启停、删除与排序
|
||||||
- [202604241553_admin-frontend-plugin-management](./2026-04/202604241553_admin-frontend-plugin-management/) - 将 `#/system/plugins` 从占位页升级为真实插件管理工作台,接入插件列表、筛选、上传、安装、启停、升级、卸载,以及 README / 动态配置抽屉
|
- [202604241553_admin-frontend-plugin-management](./2026-04/202604241553_admin-frontend-plugin-management/) - 将 `#/system/plugins` 从占位页升级为真实插件管理工作台,接入插件列表、筛选、上传、安装、启停、升级、卸载,以及 README / 动态配置抽屉
|
||||||
|
|||||||
+23
-1
@@ -26,7 +26,14 @@
|
|||||||
- `plan/fetch`
|
- `plan/fetch`
|
||||||
- 管理端节点管理现已接入:
|
- 管理端节点管理现已接入:
|
||||||
- `server/manage/getNodes`
|
- `server/manage/getNodes`
|
||||||
|
- `server/manage/save`
|
||||||
|
- `server/manage/sort`
|
||||||
- `server/group/fetch`
|
- `server/group/fetch`
|
||||||
|
- `server/group/save`
|
||||||
|
- `server/group/drop`
|
||||||
|
- `server/route/fetch`
|
||||||
|
- `server/route/save`
|
||||||
|
- `server/route/drop`
|
||||||
- `server/manage/update`
|
- `server/manage/update`
|
||||||
- `server/manage/copy`
|
- `server/manage/copy`
|
||||||
- `server/manage/drop`
|
- `server/manage/drop`
|
||||||
@@ -44,6 +51,20 @@
|
|||||||
- `order/paid`
|
- `order/paid`
|
||||||
- `order/cancel`
|
- `order/cancel`
|
||||||
- `order/update`
|
- `order/update`
|
||||||
|
- 管理端礼品卡管理现已接入:
|
||||||
|
- `gift-card/templates`
|
||||||
|
- `gift-card/create-template`
|
||||||
|
- `gift-card/update-template`
|
||||||
|
- `gift-card/delete-template`
|
||||||
|
- `gift-card/generate-codes`
|
||||||
|
- `gift-card/codes`
|
||||||
|
- `gift-card/toggle-code`
|
||||||
|
- `gift-card/export-codes`
|
||||||
|
- `gift-card/update-code`
|
||||||
|
- `gift-card/delete-code`
|
||||||
|
- `gift-card/usages`
|
||||||
|
- `gift-card/statistics`
|
||||||
|
- `gift-card/types`
|
||||||
- 管理端公告管理现已接入:
|
- 管理端公告管理现已接入:
|
||||||
- `notice/fetch`
|
- `notice/fetch`
|
||||||
- `notice/save`
|
- `notice/save`
|
||||||
@@ -71,7 +92,8 @@
|
|||||||
## 开发约定
|
## 开发约定
|
||||||
|
|
||||||
- 管理端路由使用 Hash 模式
|
- 管理端路由使用 Hash 模式
|
||||||
- 管理端当前业务路由包含 `/dashboard`、`/users`、`/tickets`、`/nodes`、`/node-groups`、`/node-routes`、`/subscriptions/plans`、`/subscriptions/orders`、`/subscriptions/coupons`、`/system/config`、`/system/notices` 与 `/system/payments`
|
- 管理端当前业务路由包含 `/dashboard`、`/users`、`/tickets`、`/nodes`、`/node-groups`、`/node-routes`、`/subscriptions/plans`、`/subscriptions/orders`、`/subscriptions/coupons`、`/subscriptions/gift-cards`、`/system/config`、`/system/notices`、`/system/payments`、`/system/plugins`、`/system/themes` 与 `/system/knowledge`
|
||||||
|
- `#/nodes` 当前已升级为真实节点工作台:支持搜索、显隐切换、复制、删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框
|
||||||
- Bearer Token 存储于 `sessionStorage/localStorage`
|
- Bearer Token 存储于 `sessionStorage/localStorage`
|
||||||
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
|
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 提供 Vue3 管理端登录页、认证状态、路由守卫和主布局
|
- 提供 Vue3 管理端登录页、认证状态、路由守卫和主布局
|
||||||
- 封装管理端统计/系统状态、用户管理、节点管理、套餐管理和系统配置接口
|
- 封装管理端统计/系统状态、用户管理、节点管理、套餐管理和系统配置接口
|
||||||
- 渲染后台仪表盘、用户管理工作台、节点管理工作台、订阅套餐管理页、系统配置页、主题管理页、插件管理工作台、公告管理工作台、支付配置工作台,以及预留的工单管理入口
|
- 渲染后台仪表盘、用户管理工作台、节点管理工作台、路由管理工作台、订阅套餐 / 订单 / 优惠券 / 礼品卡管理页、系统配置页、主题管理页、插件管理工作台、公告管理工作台、支付配置工作台,以及工单管理入口
|
||||||
|
|
||||||
## 行为规范
|
## 行为规范
|
||||||
|
|
||||||
@@ -16,18 +16,28 @@
|
|||||||
- 仪表盘“作业详情”支持打开失败作业报错弹窗,集中查看 Horizon 失败作业的报错摘要、失败时间与队列信息
|
- 仪表盘“作业详情”支持打开失败作业报错弹窗,集中查看 Horizon 失败作业的报错摘要、失败时间与队列信息
|
||||||
- 仪表盘“节点流量排行 / 用户流量排行”均支持独立的 `10个 / 20个` 显示切换,长列表固定在面板内滚动,避免首页高度失控
|
- 仪表盘“节点流量排行 / 用户流量排行”均支持独立的 `10个 / 20个` 显示切换,长列表固定在面板内滚动,避免首页高度失控
|
||||||
- `stat/getTrafficRank` 现支持 `limit=10|20`,前端会按当前排行面板的显示数量重新请求;24h 口径也继续显示涨跌百分比
|
- `stat/getTrafficRank` 现支持 `limit=10|20`,前端会按当前排行面板的显示数量重新请求;24h 口径也继续显示涨跌百分比
|
||||||
|
- `stat/getTrafficRank` 在 `24h` 口径下会按“昨天同日”统计做涨跌对比,避免日统计表因 `record_at=00:00` 被秒级窗口错位后全部回落为 `0%`
|
||||||
- 仪表盘 Hero 区提供“刷新全部数据”入口,统一触发总览、趋势、排行和系统状态刷新,并在页面内展示最近一次刷新时间
|
- 仪表盘 Hero 区提供“刷新全部数据”入口,统一触发总览、趋势、排行和系统状态刷新,并在页面内展示最近一次刷新时间
|
||||||
- 用户管理页通过真实后端 `user/fetch`、`user/update`、`user/generate`、`user/resetSecret`、`user/destroy` 与 `plan/fetch` 完成数据读写
|
- 用户管理页通过真实后端 `user/fetch`、`user/update`、`user/generate`、`user/resetSecret`、`user/destroy` 与 `plan/fetch` 完成数据读写
|
||||||
- 新增用户时采用“先 generate,后按邮箱回查并 update”的两段式流程,以兼容后端基础创建接口
|
- 新增用户时采用“先 generate,后按邮箱回查并 update”的两段式流程,以兼容后端基础创建接口
|
||||||
- 节点管理页通过真实后端 `server/manage/getNodes` 与 `server/group/fetch` 获取列表,并通过 `server/manage/update`、`server/manage/copy`、`server/manage/drop` 完成首批行级操作
|
- 节点管理页通过真实后端 `server/manage/getNodes`、`server/group/fetch` 与 `server/route/fetch` 获取列表 / 关联数据,并通过 `server/manage/save`、`server/manage/sort`、`server/manage/update`、`server/manage/copy`、`server/manage/drop` 完成新增、编辑、排序与行级操作
|
||||||
- 节点相关导航入口固定归入“节点管理”分组;`/node-groups` 与 `/node-routes` 本轮先交付结构化占位页,不伪装为完整功能
|
- 节点新增 / 编辑采用统一中央大弹窗,支持 `Shadowsocks / VMess / Trojan / Hysteria / VLess / TUIC / SOCKS / Naive / HTTP / Mieru / AnyTLS` 11 种协议的首版动态配置表单
|
||||||
- 订阅管理新增独立“订阅管理”侧边栏分组,现已完整实现 `#/subscriptions/plans`、`#/subscriptions/orders` 与 `#/subscriptions/coupons`;礼品卡管理仍保留禁用态
|
- 节点排序采用本地草稿 + 上移 / 下移模式,保存时向 `server/manage/sort` 提交 `{ id, order }[]` 顺序 payload
|
||||||
|
- 权限组管理页使用真实后端 `server/group/fetch`、`server/group/save` 与 `server/group/drop`,支持关键字搜索、新增/编辑中央弹窗、删除确认,以及从节点数量列跳转到 `#/nodes?group={id}` 的筛选联动
|
||||||
|
- 路由管理页使用真实后端 `server/route/fetch`、`server/route/save` 与 `server/route/drop`,支持路由列表、关键词搜索、新增/编辑中央弹窗、删除与动作值展示
|
||||||
|
- 路由管理页的节点引用摘要由 `server/manage/getNodes` 返回的 `route_ids` 推导,不在前端伪造额外接口
|
||||||
|
- 节点页会读取路由查询中的 `group` 参数并自动应用对应权限组筛选,同时提供“管理权限组”入口回到权限组页;`/node-routes` 已升级为真实工作台
|
||||||
|
- 订阅管理新增独立“订阅管理”侧边栏分组,现已完整实现 `#/subscriptions/plans`、`#/subscriptions/orders`、`#/subscriptions/coupons` 与 `#/subscriptions/gift-cards`
|
||||||
- 套餐管理页使用真实后端 `plan/fetch`、`plan/save`、`plan/update`、`plan/drop`、`plan/sort` 与 `server/group/fetch`
|
- 套餐管理页使用真实后端 `plan/fetch`、`plan/save`、`plan/update`、`plan/drop`、`plan/sort` 与 `server/group/fetch`
|
||||||
|
- 侧边栏在低窗口高度下采用“顶部品牌区固定 + 菜单区独立纵向滚动”的结构,避免新增分组后底部导航入口被直接裁切
|
||||||
- 套餐管理页渲染 `ElSwitch` 前,会先把 `show / sell / renew` 归一化成布尔值;开关事件若新旧值相同则直接短路,避免初始化阶段误写后台状态
|
- 套餐管理页渲染 `ElSwitch` 前,会先把 `show / sell / renew` 归一化成布尔值;开关事件若新旧值相同则直接短路,避免初始化阶段误写后台状态
|
||||||
- 套餐说明编辑采用轻量 Markdown/HTML 编辑器与预览模式,不引入额外富文本依赖
|
- 套餐说明编辑采用轻量 Markdown/HTML 编辑器与预览模式,不引入额外富文本依赖
|
||||||
- 订单管理页使用真实后端 `order/fetch`、`order/detail`、`order/assign`、`order/paid`、`order/cancel` 与 `order/update`,支持订单列表、类型/周期/状态筛选、详情抽屉、手动分配、人工标记已支付与佣金状态维护
|
- 订单管理页使用真实后端 `order/fetch`、`order/detail`、`order/assign`、`order/paid`、`order/cancel` 与 `order/update`,支持订单列表、类型/周期/状态筛选、详情抽屉、手动分配、人工标记已支付与佣金状态维护
|
||||||
- 订单金额、佣金金额与相关拆解字段以“分”为后端真相源,前端统一在 `src/utils/orders.ts` 中格式化为“元”展示,避免后台金额口径混乱
|
- 订单金额、佣金金额与相关拆解字段以“分”为后端真相源,前端统一在 `src/utils/orders.ts` 中格式化为“元”展示,避免后台金额口径混乱
|
||||||
- 优惠券管理页使用真实后端 `coupon/fetch`、`coupon/generate`、`coupon/update` 与 `coupon/drop`,支持本地搜索、类型筛选、启停、删除与弹窗式新增/编辑
|
- 优惠券管理页使用真实后端 `coupon/fetch`、`coupon/generate`、`coupon/update` 与 `coupon/drop`,支持本地搜索、类型筛选、启停、删除与弹窗式新增/编辑
|
||||||
|
- 礼品卡管理页使用真实后端 `gift-card/templates`、`gift-card/create-template`、`gift-card/update-template`、`gift-card/delete-template`、`gift-card/generate-codes`、`gift-card/codes`、`gift-card/toggle-code`、`gift-card/export-codes`、`gift-card/update-code`、`gift-card/delete-code`、`gift-card/usages`、`gift-card/statistics` 与 `gift-card/types`
|
||||||
|
- 礼品卡工作台采用单页四页签结构,覆盖模板管理、兑换码管理、使用记录和统计数据;模板编辑使用分组式大抽屉,兑换码生成使用独立对话框
|
||||||
|
- 礼品卡模板的 `conditions / rewards / limits / special_config` 映射统一收敛到 `src/utils/giftCards.ts`,避免表单展示结构与提交 JSON 结构漂移
|
||||||
- 优惠券编辑弹窗支持金额/比例两种优惠类型、有效期范围、批量生成、自定义券码、指定周期与指定订阅限制
|
- 优惠券编辑弹窗支持金额/比例两种优惠类型、有效期范围、批量生成、自定义券码、指定周期与指定订阅限制
|
||||||
- 系统管理新增独立“系统管理”侧边栏分组,当前已完整实现 `#/system/config`、`#/system/themes`、`#/system/plugins`、`#/system/notices`、`#/system/payments` 与 `#/system/knowledge`
|
- 系统管理新增独立“系统管理”侧边栏分组,当前已完整实现 `#/system/config`、`#/system/themes`、`#/system/plugins`、`#/system/notices`、`#/system/payments` 与 `#/system/knowledge`
|
||||||
- 系统配置页使用真实后端 `config/fetch`、`config/save`、`config/testSendMail` 与 `config/setTelegramWebhook`,并按站点、安全、订阅、邀请佣金、节点、邮件、Telegram、APP、订阅模板 9 个分组组织长表单
|
- 系统配置页使用真实后端 `config/fetch`、`config/save`、`config/testSendMail` 与 `config/setTelegramWebhook`,并按站点、安全、订阅、邀请佣金、节点、邮件、Telegram、APP、订阅模板 9 个分组组织长表单
|
||||||
@@ -42,6 +52,7 @@
|
|||||||
- 支付编辑抽屉根据所选支付接口动态拉取真实配置字段,不在前端写死 EPay / TokenPay 等网关表单;通知地址继续以后端拼接结果为准
|
- 支付编辑抽屉根据所选支付接口动态拉取真实配置字段,不在前端写死 EPay / TokenPay 等网关表单;通知地址继续以后端拼接结果为准
|
||||||
- 知识库管理页使用真实后端 `knowledge/fetch`、`knowledge/getCategory`、`knowledge/save`、`knowledge/show`、`knowledge/drop` 与 `knowledge/sort`,支持标题搜索、分类筛选、显隐切换、编辑弹窗、删除与排序模式
|
- 知识库管理页使用真实后端 `knowledge/fetch`、`knowledge/getCategory`、`knowledge/save`、`knowledge/show`、`knowledge/drop` 与 `knowledge/sort`,支持标题搜索、分类筛选、显隐切换、编辑弹窗、删除与排序模式
|
||||||
- 知识编辑弹窗继续使用轻量 Markdown/HTML 工具栏,不引入额外富文本依赖;编辑时会单独请求详情补齐 `body / language`
|
- 知识编辑弹窗继续使用轻量 Markdown/HTML 工具栏,不引入额外富文本依赖;编辑时会单独请求详情补齐 `body / language`
|
||||||
|
- `#/system/knowledge` 当前直接渲染 `SystemKnowledgeView` 真实页面,不再回退 `SystemPlaceholderView`
|
||||||
- 当前首页视觉基线为 Apple 风格:纯色分区、系统字体栈、单一蓝色强调和轻量层次
|
- 当前首页视觉基线为 Apple 风格:纯色分区、系统字体栈、单一蓝色强调和轻量层次
|
||||||
- 性能优化优先级高于装饰性表达,避免远程字体、全局模糊背景和固定特效层
|
- 性能优化优先级高于装饰性表达,避免远程字体、全局模糊背景和固定特效层
|
||||||
|
|
||||||
@@ -51,12 +62,15 @@
|
|||||||
- 依赖 `src/utils/users.ts` 负责用户管理表单转换、筛选组装和状态计算
|
- 依赖 `src/utils/users.ts` 负责用户管理表单转换、筛选组装和状态计算
|
||||||
- 依赖 `src/utils/plans.ts` 负责套餐价格、说明渲染、排序与表单转换
|
- 依赖 `src/utils/plans.ts` 负责套餐价格、说明渲染、排序与表单转换
|
||||||
- 依赖 `src/utils/orders.ts` 负责订单金额换算、状态映射、周期标签与筛选参数组装
|
- 依赖 `src/utils/orders.ts` 负责订单金额换算、状态映射、周期标签与筛选参数组装
|
||||||
|
- 依赖 `src/utils/nodeGroups.ts` 负责权限组计数归一化、本地搜索与摘要计算
|
||||||
- 依赖 `src/utils/coupons.ts` 负责优惠券类型映射、时间范围转换、过期状态计算与表单序列化
|
- 依赖 `src/utils/coupons.ts` 负责优惠券类型映射、时间范围转换、过期状态计算与表单序列化
|
||||||
|
- 依赖 `src/utils/giftCards.ts` 负责礼品卡类型/状态映射、模板表单序列化、金额/流量换算与本地筛选逻辑
|
||||||
- 依赖 `src/utils/themes.ts` 负责主题列表排序、动态配置默认值回填与序列化
|
- 依赖 `src/utils/themes.ts` 负责主题列表排序、动态配置默认值回填与序列化
|
||||||
- 依赖 `src/utils/plugins.ts` 负责插件状态判断、README 渲染、筛选与动态配置表单序列化
|
- 依赖 `src/utils/plugins.ts` 负责插件状态判断、README 渲染、筛选与动态配置表单序列化
|
||||||
- 依赖 `src/utils/payments.ts` 负责支付方式归一化、搜索过滤、排序移动与动态配置序列化
|
- 依赖 `src/utils/payments.ts` 负责支付方式归一化、搜索过滤、排序移动与动态配置序列化
|
||||||
- 依赖 `src/utils/knowledge.ts` 负责知识库分类、Markdown 渲染、过滤与表单转换
|
- 依赖 `src/utils/knowledge.ts` 负责知识库分类、Markdown 渲染、过滤与表单转换
|
||||||
- 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化
|
- 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化
|
||||||
- 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化
|
- 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化
|
||||||
|
- 依赖 `src/utils/routes.ts` 负责路由动作映射、匹配规则序列化、节点引用摘要与搜索过滤
|
||||||
- 依赖 Laravel 注入的 `window.settings`
|
- 依赖 Laravel 注入的 `window.settings`
|
||||||
- 构建输出到 `public/assets/admin`
|
- 构建输出到 `public/assets/admin`
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"done": 4,
|
"done": 4,
|
||||||
"percent": 100,
|
"percent": 100,
|
||||||
"current": "知识库管理页已完成并通过构建验证",
|
"current": "知识库管理页已完成并通过构建验证",
|
||||||
"updated_at": "2026-04-24 16:24:00"
|
"updated_at": "2026-04-24 17:03:49"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 任务清单: admin-frontend-knowledge-management
|
# 任务清单: admin-frontend-knowledge-management
|
||||||
|
|
||||||
> **@status:** completed | 2026-04-24 16:24
|
> **@status:** completed | 2026-04-24 17:03
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@feature: admin-frontend-knowledge-management
|
@feature: admin-frontend-knowledge-management
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
| 时间 | 任务 | 状态 | 备注 |
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 2026-04-24 16:10 | 方案包初始化 | completed | 用户已确认采用轻量 Markdown 编辑器方案 |
|
| 2026-04-24 16:10 | 方案包初始化 | completed | 用户已确认采用轻量 Markdown 编辑器方案 |
|
||||||
| 2026-04-24 16:18 | 页面实现 | completed | 已接入知识列表、分类筛选、编辑弹窗、显隐切换与排序对话框 |
|
| 2026-04-24 16:18 | 页面实现 | completed | 已补齐知识列表、分类筛选、详情编辑弹窗、显隐切换、删除与排序对话框,并将 `/system/knowledge` 从占位页切换为真实页面 |
|
||||||
| 2026-04-24 16:22 | 构建验证 | completed | `admin-frontend` 执行 `npm run build` 通过,并输出知识库页面产物 |
|
| 2026-04-24 17:01 | 构建验证 | completed | `admin-frontend` 重新执行 `npm run build` 通过,并刷新 `public/assets/admin` 最新知识库页面产物 |
|
||||||
| 2026-04-24 16:24 | 文档同步 | completed | 已更新 CHANGELOG、模块文档与状态快照 |
|
| 2026-04-24 17:03 | 文档同步 | completed | 已更新 CHANGELOG、模块文档、方案包时间戳与状态快照,修正文档先于代码落地的不一致 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"completed": 4,
|
||||||
|
"failed": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"total": 4,
|
||||||
|
"done": 4,
|
||||||
|
"percent": 100,
|
||||||
|
"current": "路由管理真实工作台、节点引用摘要与构建验证已完成",
|
||||||
|
"updated_at": "2026-04-24 17:08:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-04-24T09:01:00.000Z",
|
||||||
|
"version": 1,
|
||||||
|
"source": "R2",
|
||||||
|
"originCommand": "design",
|
||||||
|
"verifyMode": "review-first",
|
||||||
|
"reviewerFocus": [
|
||||||
|
"路由管理页面是否贴近用户截图的 Apple 风格后台结构",
|
||||||
|
"列表、编辑弹窗与节点引用摘要是否覆盖用户本轮确认的范围",
|
||||||
|
"节点管理其他页面与导航边界是否未被误改"
|
||||||
|
],
|
||||||
|
"testerFocus": [
|
||||||
|
"路由列表是否真实连接 /server/route/fetch",
|
||||||
|
"新增编辑是否真实连接 /server/route/save,删除是否连接 /server/route/drop",
|
||||||
|
"节点引用摘要是否由 /server/manage/getNodes 的 route_ids 正确推导,且 admin-frontend 构建通过"
|
||||||
|
],
|
||||||
|
"ui": {
|
||||||
|
"required": true,
|
||||||
|
"designContract": true,
|
||||||
|
"sourcePriority": [
|
||||||
|
"plan.md",
|
||||||
|
".helloagents/DESIGN.md",
|
||||||
|
"hello-ui"
|
||||||
|
],
|
||||||
|
"styleAdvisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": []
|
||||||
|
},
|
||||||
|
"visualValidation": {
|
||||||
|
"required": true,
|
||||||
|
"reason": "路由管理属于真实后台 CRUD 页,需要确认列表页与编辑弹窗符合 Apple 风格后台契约,并补足节点引用摘要可读性",
|
||||||
|
"screens": [
|
||||||
|
"#/node-routes desktop",
|
||||||
|
"#/node-routes editor dialog"
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
"路由列表加载完成态",
|
||||||
|
"新增/编辑弹窗态"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": [],
|
||||||
|
"preferredSources": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 变更提案: admin-frontend-node-route-management
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 功能增强
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已完成
|
||||||
|
创建: 2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
`admin-frontend` 当前已经完成节点管理主工作台,但 `#/node-routes` 仍停留在结构化占位页。用户已提供目标截图,并在本轮确认选择“CRUD 首版 + 节点引用摘要”方案,希望继续把路由管理做成真实可用的后台页面。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 将 `#/node-routes` 从占位页升级为真实可用的路由管理工作台。
|
||||||
|
- 支持路由列表查看、关键词搜索、新增、编辑、删除与动作值展示。
|
||||||
|
- 在列表中补充每条路由当前被多少节点引用、以及部分节点名预览,帮助运营判断影响面。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
范围约束: 本轮仅实现路由管理工作台与节点引用摘要,不扩展为完整拓扑/影响面详情页
|
||||||
|
技术约束: 继续使用 Vue3 + TypeScript + Element Plus,不新增第三方依赖
|
||||||
|
视觉约束: 贴近用户截图中的“黑色 hero + 白色表格工作台 + 中央编辑弹窗”结构,并继续遵循 apple/DESIGN.md 与 .helloagents/DESIGN.md
|
||||||
|
业务约束: 后端真相源固定为 server/route/fetch、server/route/save、server/route/drop;节点引用摘要由 server/manage/getNodes 的 route_ids 推导
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] `#/node-routes` 可真实拉取路由列表,并显示 ID、备注、动作值、动作类型与操作列。
|
||||||
|
- [ ] 页面支持关键词搜索,搜索范围覆盖备注、匹配规则、动作值与引用节点名称。
|
||||||
|
- [ ] 支持新增/编辑路由,字段覆盖备注、匹配规则、动作与按需出现的动作值。
|
||||||
|
- [ ] 支持删除路由,并给出明确成功/失败反馈。
|
||||||
|
- [ ] 列表中可看到每条路由的节点引用摘要,包括引用数量与部分节点名预览。
|
||||||
|
- [ ] `admin-frontend` 执行 `npm run build` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 页面结构
|
||||||
|
1. 顶部保留黑色 hero,左侧说明“路由管理”的职责,右侧展示总路由数、禁止访问、DNS 解析与已引用路由数。
|
||||||
|
2. 主工作区采用白色表格容器,工具条只保留“添加路由”和“搜索路由”,贴近用户截图。
|
||||||
|
3. 列表主体展示:
|
||||||
|
- 路由 ID
|
||||||
|
- 备注(含节点引用摘要)
|
||||||
|
- 动作值(主值 + 匹配规则条数)
|
||||||
|
- 动作标签
|
||||||
|
- 编辑 / 删除操作
|
||||||
|
4. 编辑路由采用中央 `ElDialog`,表单结构与截图一致:备注、匹配规则、动作;当动作为 `dns` 或 `proxy` 时,再补充动作值输入。
|
||||||
|
|
||||||
|
### 前端实现策略
|
||||||
|
1. 在 `src/types/api.d.ts` 中新增路由实体与保存载荷定义。
|
||||||
|
2. 在 `src/api/admin.ts` 中新增路由管理接口封装:`fetchNodeRoutes / saveNodeRoute / deleteNodeRoute`。
|
||||||
|
3. 新增 `src/utils/routes.ts`,统一处理:
|
||||||
|
- 路由实体归一化
|
||||||
|
- 搜索过滤
|
||||||
|
- 动作标签 / 动作值映射
|
||||||
|
- 表单模型转换
|
||||||
|
- 节点引用摘要生成
|
||||||
|
4. 新增:
|
||||||
|
- `src/views/nodes/NodeRoutesView.vue`
|
||||||
|
- `src/views/nodes/NodeRoutesView.scss`
|
||||||
|
- `src/views/nodes/NodeRouteEditorDialog.vue`
|
||||||
|
- `src/views/nodes/NodeRouteEditorDialog.scss`
|
||||||
|
5. 保持 `src/router/index.ts` 与 `AdminLayout` 既有入口不变,只替换占位页面实现。
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 后端 `match` 字段要求数组,前端截图却是 textarea 录入 | 中 | 编辑器继续使用多行 textarea,提交前按行切分并去空,拉取后再回填为换行文本 |
|
||||||
|
| `action_value` 仅部分动作需要 | 中 | 在工具层与表单层统一定义“需要动作值”的动作集合,仅在 `dns / proxy` 时展示与校验 |
|
||||||
|
| 节点引用摘要并非后端直接返回字段 | 低 | 与路由列表并行拉取节点列表,通过 `route_ids` 本地推导引用数量与名称摘要 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术决策
|
||||||
|
|
||||||
|
### admin-frontend-node-route-management#D001: 路由列表采用“截图同款表格 + 中央编辑弹窗”
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户已经给出目标截图,并明确希望继续完成真实路由管理工作台。
|
||||||
|
**决策**: 列表、搜索与删除保留在主页面;新增/编辑统一进入中央弹窗。
|
||||||
|
**理由**: 最符合当前后台已有公告/知识库工作流,也最贴近用户截图。
|
||||||
|
|
||||||
|
### admin-frontend-node-route-management#D002: 节点引用摘要放在列表备注区,而不扩展成独立拓扑视图
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户本轮选择的是“CRUD 首版 + 节点引用摘要”,不是完整影响面可视化。
|
||||||
|
**决策**: 只在列表中提供“引用节点数 + 部分节点名预览”的只读摘要,不新增复杂详情工作台。
|
||||||
|
**理由**: 能满足当前运营判断需求,同时控制本轮范围。
|
||||||
|
|
||||||
|
### admin-frontend-node-route-management#D003: 动作值仅在 `dns / proxy` 时显示独立输入
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 后端允许 `action_value` 为空,截图中“禁止访问 / 直连”也没有额外输入框。
|
||||||
|
**决策**: `block / direct` 不展示动作值输入;`dns / proxy` 才显示并做非空校验。
|
||||||
|
**理由**: 能与后端契约、用户截图和运营认知保持一致,降低表单噪音。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 任务清单: admin-frontend-node-route-management
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-24 17:08
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: admin-frontend-node-route-management
|
||||||
|
@created: 2026-04-24
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 4 | 0 | 0 | 4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
- [√] 1. 梳理路由管理后端接口、字段边界与当前节点管理设计契约
|
||||||
|
- [√] 2. 实现路由 API/类型/工具层与真实列表页面
|
||||||
|
- [√] 3. 实现路由编辑弹窗、删除流程与节点引用摘要
|
||||||
|
- [√] 4. 运行 `admin-frontend` 构建验证,并同步 `.helloagents` 记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-24 17:01 | 方案包初始化 | completed | 用户选择“CRUD 首版 + 节点引用摘要”方案 |
|
||||||
|
| 2026-04-24 17:05 | 页面实现 | completed | 已接入路由列表、动作映射、编辑弹窗、删除流程与节点引用摘要 |
|
||||||
|
| 2026-04-24 17:07 | 构建验证 | completed | `admin-frontend` 执行 `npm run build` 通过,并刷新 `public/assets/admin` 产物 |
|
||||||
|
| 2026-04-24 17:08 | 文档同步 | completed | 已更新 CHANGELOG、项目上下文与模块文档 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- 当前仓存在未提交的历史变更与多个已完成方案包,本轮只增量实现 `node-routes`,不覆盖无关模块。
|
||||||
|
- `public/assets/admin` 为前端构建产物子模块;构建后需要同时复核根仓与子模块状态。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"completed": 4,
|
||||||
|
"failed": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"total": 4,
|
||||||
|
"done": 4,
|
||||||
|
"percent": 100,
|
||||||
|
"current": "24h 排行对昨天比较修复、测试补充与语法校验已完成",
|
||||||
|
"updated_at": "2026-04-24 19:25:00"
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-04-24T11:25:00.000Z",
|
||||||
|
"version": 1,
|
||||||
|
"source": "R2",
|
||||||
|
"originCommand": "design",
|
||||||
|
"verifyMode": "targeted-check",
|
||||||
|
"reviewerFocus": [
|
||||||
|
"24h 排行比较窗口是否准确命中昨天整日统计",
|
||||||
|
"7天 / 30天 对比逻辑是否保持原样,未被意外改动",
|
||||||
|
"节点排行与用户排行是否共用同一后端修复入口"
|
||||||
|
],
|
||||||
|
"testerFocus": [
|
||||||
|
"单日窗口应返回昨天整日作为 previous window",
|
||||||
|
"多日窗口应继续沿用原等跨度比较规则",
|
||||||
|
"修改文件至少通过 PHP 语法校验"
|
||||||
|
],
|
||||||
|
"ui": {
|
||||||
|
"required": false,
|
||||||
|
"designContract": false,
|
||||||
|
"sourcePriority": [],
|
||||||
|
"styleAdvisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": []
|
||||||
|
},
|
||||||
|
"visualValidation": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"screens": [],
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": [],
|
||||||
|
"preferredSources": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 变更提案: admin-frontend-dashboard-rank-24h-compare
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 缺陷修复
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已完成
|
||||||
|
创建: 2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
用户反馈 `admin-frontend` 仪表盘中的“节点流量排行 / 用户流量排行”在 `24h` 视图下,右侧趋势百分比始终显示 `0%`。当前期望是:`24h` 口径应与“昨天同日统计”做对比,而不是固定归零。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 修复 `24h` 排行涨跌百分比的对比窗口。
|
||||||
|
- 保持本轮范围最小,只处理 `24h` 口径,不改动 `7天 / 30天` 的既有对比方式。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
范围约束: 仅修复 admin 仪表盘 traffic rank 的 24h 对比逻辑
|
||||||
|
技术约束: 优先做后端定点修复,不改动前端排行展示结构
|
||||||
|
业务约束: 24h 必须与昨天整日统计对比;7天/30天 本轮保持现状
|
||||||
|
验证约束: 本地无 composer vendor,无法直接跑 Laravel/PHPUnit 全量测试
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] `stat/getTrafficRank` 在单日窗口下返回的 `change` 不再因昨日统计行被排除而全部回退为 `0`
|
||||||
|
- [ ] 节点排行与用户排行共用同一修复逻辑
|
||||||
|
- [ ] 修改文件通过基础语法校验
|
||||||
|
- [ ] `.helloagents` 变更记录已同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
后端 `StatUserJob / StatServerJob` 会把日统计记录写入当天 `00:00:00` 的 `record_at`。现有 `getTrafficRank` 在单日窗口下使用“当前秒级跨度回推上一窗口”的方式计算:
|
||||||
|
|
||||||
|
- 当前窗口:`2026-04-24 00:00:00 ~ 2026-04-24 23:59:59`
|
||||||
|
- 旧逻辑上一窗口起点:`2026-04-23 00:00:01`
|
||||||
|
|
||||||
|
这样会把昨天 `00:00:00` 的整日统计记录排除掉,导致 `previousValue = 0`,最终 `change` 全部落回 `0`。
|
||||||
|
|
||||||
|
### 修复策略
|
||||||
|
1. 在 `StatController` 中新增单独的对比窗口解析方法。
|
||||||
|
2. 当当前窗口是单日口径时,上一窗口直接固定为 `start_time - 86400 ~ start_time`,精确命中昨天整日统计。
|
||||||
|
3. 多日窗口沿用现有等跨度逻辑,避免超出本轮范围。
|
||||||
|
4. 增加单元测试覆盖窗口计算规则,便于后续回归。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术决策
|
||||||
|
|
||||||
|
### admin-frontend-dashboard-rank-24h-compare#D001: 仅修复 24h 与昨天对比逻辑,7天/30天 保持现状
|
||||||
|
**日期**: 2026-04-24
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户在确认阶段明确选择“只修复 24h,对昨天同时间窗口比较;7天 / 30天 保持现状”。
|
||||||
|
**决策**: 后端只对单日窗口切换为“昨天整日”比较,多日窗口不在本轮调整。
|
||||||
|
**理由**: 与用户确认范围一致,改动最小,回归风险最低。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 任务清单: admin-frontend-dashboard-rank-24h-compare
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-24 19:25
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: admin-frontend-dashboard-rank-24h-compare
|
||||||
|
@created: 2026-04-24
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 4 | 0 | 0 | 4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
- [√] 1. 定位 24h 排行涨跌全部为 0 的根因,确认前后端边界
|
||||||
|
- [√] 2. 在后端实现单日窗口与昨天整日统计的定点对比修复
|
||||||
|
- [√] 3. 补充单元测试覆盖窗口解析逻辑
|
||||||
|
- [√] 4. 进行语法校验并同步 `.helloagents` 记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-24 19:13 | 根因定位 | completed | 确认日统计 `record_at` 固定为当天 00:00,旧窗口回推会把昨天记录错位排除 |
|
||||||
|
| 2026-04-24 19:18 | 后端修复 | completed | `StatController` 新增单日窗口解析逻辑,仅修复 24h 对昨天比较 |
|
||||||
|
| 2026-04-24 19:21 | 测试补充 | completed | 新增窗口解析单元测试,覆盖单日与多日两条路径 |
|
||||||
|
| 2026-04-24 19:25 | 校验与文档同步 | completed | 已执行 PHP 语法校验,并更新模块文档与 CHANGELOG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- 本地环境缺少 `vendor/autoload.php` 与 `vendor/bin/phpunit`,本轮无法执行 PHPUnit;已退化为语法校验,并保留测试文件供后续有依赖环境时执行。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-04-24T09:09:46.384Z",
|
||||||
|
"version": 1,
|
||||||
|
"source": "manual",
|
||||||
|
"originCommand": "generic-r2",
|
||||||
|
"verifyMode": "test-first",
|
||||||
|
"reviewerFocus": [
|
||||||
|
"礼品卡管理整体结构是否贴近用户提供的四页签后台截图",
|
||||||
|
"模板抽屉字段分组是否与截图和现有后端 JSON 结构一致"
|
||||||
|
],
|
||||||
|
"testerFocus": [
|
||||||
|
"礼品卡管理是否真实连接 gift-card/templates、codes、usages、statistics、types 接口",
|
||||||
|
"模板新增编辑是否能正确序列化 conditions、rewards、limits、special_config",
|
||||||
|
"兑换码管理是否支持生成、导出、复制、启停、编辑和删除主链路"
|
||||||
|
],
|
||||||
|
"ui": {
|
||||||
|
"required": true,
|
||||||
|
"designContract": true,
|
||||||
|
"sourcePriority": [
|
||||||
|
"requirements.md",
|
||||||
|
".helloagents/DESIGN.md",
|
||||||
|
"hello-ui"
|
||||||
|
],
|
||||||
|
"styleAdvisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": []
|
||||||
|
},
|
||||||
|
"visualValidation": {
|
||||||
|
"required": true,
|
||||||
|
"reason": "礼品卡管理属于整页新增的真实后台业务页,需要确认四页签导航、模板表格、模板抽屉和统计卡片在代码结构上与目标截图及 Apple 化后台契约一致。",
|
||||||
|
"screens": [
|
||||||
|
"#/subscriptions/gift-cards desktop"
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
"模板管理默认加载完成态",
|
||||||
|
"模板新增抽屉展开态",
|
||||||
|
"兑换码管理列表态",
|
||||||
|
"统计数据总览态"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": [],
|
||||||
|
"preferredSources": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# admin-frontend 礼品卡管理首版交付 — 实施规划
|
||||||
|
|
||||||
|
## 目标与范围
|
||||||
|
- 在现有订阅管理分组中补齐礼品卡管理真实页,替换原先的禁用入口。
|
||||||
|
- 页面聚焦“礼品卡运营工作台”主链路:建模板、生成兑换码、查看使用记录、追踪统计数据。
|
||||||
|
|
||||||
|
## 架构与实现策略
|
||||||
|
- 在 `AdminLayout` 中开放 `/subscriptions/gift-cards` 导航入口,并在路由中新增对应页面。
|
||||||
|
- 新增 `GiftCardsView` 作为整页工作台,整体结构参考用户截图:
|
||||||
|
- 顶部标题与说明
|
||||||
|
- 四段式分段导航(模板管理 / 兑换码管理 / 使用记录 / 统计数据)
|
||||||
|
- 每个页签独立的筛选条、表格/卡片内容与操作按钮
|
||||||
|
- 新增两个业务弹层组件:
|
||||||
|
- `GiftCardTemplateDrawer.vue`:负责模板新增与编辑
|
||||||
|
- `GiftCardCodeBatchDialog.vue`:负责批量生成兑换码
|
||||||
|
- 在 `src/utils/giftCards.ts` 中集中处理:
|
||||||
|
- 类型/状态映射
|
||||||
|
- 模板表单序列化与反序列化
|
||||||
|
- 金额(元)/流量(GB)/倍率/日期等展示与提交格式转换
|
||||||
|
- 本地搜索筛选与统计卡片整理
|
||||||
|
- API 层在 `src/api/admin.ts` 中新增礼品卡接口封装;类型定义统一补到 `src/types/api.d.ts`。
|
||||||
|
|
||||||
|
## 完成定义
|
||||||
|
- 侧边栏中的“礼品卡管理”不再是禁用入口,能正常进入 `#/subscriptions/gift-cards`。
|
||||||
|
- 模板管理页可真实连接模板列表与 CRUD 接口,支持搜索、筛选、启停与删除。
|
||||||
|
- 模板抽屉可完整编辑截图展示的主要字段分组,并正确序列化为后端 `conditions / rewards / limits / special_config` 结构。
|
||||||
|
- 兑换码页可真实连接兑换码列表,支持批量生成、复制、启停、导出当前批次、编辑有效期/次数与删除。
|
||||||
|
- 使用记录与统计数据页可真实连接后端数据,不使用硬编码假数据。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
- `admin-frontend/src/layouts/AdminLayout.vue`
|
||||||
|
- `admin-frontend/src/router/index.ts`
|
||||||
|
- `admin-frontend/src/api/admin.ts`
|
||||||
|
- `admin-frontend/src/types/api.d.ts`
|
||||||
|
- `admin-frontend/src/utils/giftCards.ts`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardsView.vue`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardsView.scss`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardTemplateDrawer.vue`
|
||||||
|
- `admin-frontend/src/views/subscriptions/GiftCardCodeBatchDialog.vue`
|
||||||
|
|
||||||
|
## UI / 设计约束
|
||||||
|
- 页面采用“标题说明 + 轻量分段页签 + 白色工作台”的 Apple 化运营后台节奏,不额外叠加夸张 hero 或营销化视觉。
|
||||||
|
- 四个页签保持统一信息架构与表格密度,让用户能快速在模板、兑换码、记录、统计之间切换。
|
||||||
|
- 模板抽屉使用分组 section 和双列表单布局,对齐截图中的信息分区;在窄屏下自动堆叠为单列。
|
||||||
|
- 状态标签、奖励摘要、统计卡片继续沿用单一蓝色强调和语义色状态胶囊,不引入新配色体系。
|
||||||
|
|
||||||
|
## 风险与验证
|
||||||
|
- 风险 1:模板表单字段较多,若直接散落在组件内易导致提交结构和展示结构不一致,因此统一收敛到 `src/utils/giftCards.ts`。
|
||||||
|
- 风险 2:兑换码列表接口不支持关键词搜索,需要前端在当前拉取结果上做本地搜索,并明确这是列表内过滤。
|
||||||
|
- 风险 3:本地环境缺少真实后台登录态时,只能做结构与构建验证,不能替代完整联调。
|
||||||
|
- 验证方式:
|
||||||
|
- `npm run build`
|
||||||
|
- 代码级结构自检 `#/subscriptions/gift-cards`
|
||||||
|
- 结构化视觉验收记录(无浏览器工具时以 code inspection 说明边界)
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
- [2026-04-24] 礼品卡管理采用单页四段式导航,而不是四个独立路由,以贴近用户截图中的运营切换路径。
|
||||||
|
- [2026-04-24] 模板抽屉使用分组式大表单,不把复杂字段塞进居中弹窗,以保证高密度配置仍可读。
|
||||||
|
- [2026-04-24] 兑换码导出先按“当前选中批次”提供显式出口,不额外扩展复杂多选批量导出流程。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# admin-frontend 礼品卡管理首版交付 — 需求
|
||||||
|
|
||||||
|
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
- 在 `admin-frontend` 中开放“订阅管理 / 礼品卡管理”入口,不再保留禁用态。
|
||||||
|
- 参考用户提供的 5 张截图,交付礼品卡管理真实工作台,覆盖模板管理、兑换码管理、使用记录与统计数据四个页签。
|
||||||
|
- 保持 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md` 定义的 Apple 化后台视觉语言,同时优先贴近截图中的高密度运营视图与轻量分段导航。
|
||||||
|
|
||||||
|
## 功能边界
|
||||||
|
- 必须实现 `#/subscriptions/gift-cards` 真实页面。
|
||||||
|
- 页面必须包含:
|
||||||
|
- 顶部标题说明与四段式页签导航
|
||||||
|
- 模板管理列表、搜索、类型/状态筛选、显隐切换、新增/编辑、删除
|
||||||
|
- 模板新增/编辑大表单,覆盖基础配置、奖励内容、使用条件、使用限制、特殊配置、显示效果
|
||||||
|
- 兑换码管理列表、模板/状态筛选、复制、启停、编辑、删除、批量生成、批次导出
|
||||||
|
- 使用记录列表与用户邮箱搜索
|
||||||
|
- 统计数据总览,至少展示模板总数、活跃模板数、兑换码总数、已使用兑换码
|
||||||
|
- 必须接入现有 Laravel 管理接口:
|
||||||
|
- `GET /gift-card/templates`
|
||||||
|
- `POST /gift-card/create-template`
|
||||||
|
- `POST /gift-card/update-template`
|
||||||
|
- `POST /gift-card/delete-template`
|
||||||
|
- `POST /gift-card/generate-codes`
|
||||||
|
- `GET /gift-card/codes`
|
||||||
|
- `POST /gift-card/toggle-code`
|
||||||
|
- `GET /gift-card/export-codes`
|
||||||
|
- `POST /gift-card/update-code`
|
||||||
|
- `POST /gift-card/delete-code`
|
||||||
|
- `GET /gift-card/usages`
|
||||||
|
- `GET /gift-card/statistics`
|
||||||
|
- `GET /gift-card/types`
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
- 本轮不改造 Laravel 礼品卡后端逻辑、校验规则或数据库结构。
|
||||||
|
- 本轮不实现用户端礼品卡兑换体验。
|
||||||
|
- 本轮不引入复杂图表库,只使用现有栈完成统计展示。
|
||||||
|
|
||||||
|
## 技术约束
|
||||||
|
- 技术栈固定为 `Vue 3 + TypeScript + Vite + Element Plus`。
|
||||||
|
- 后端真相源以仓库内 `App\Http\Controllers\V2\Admin\GiftCardController`、`GiftCardTemplate`、`GiftCardCode` 与 `GiftCardUsage` 为准。
|
||||||
|
- 构建验证使用 `admin-frontend/package.json` 中已有 `npm run build`。
|
||||||
|
- 构建产物继续输出到 `public/assets/admin` 子模块。
|
||||||
|
|
||||||
|
## 质量要求
|
||||||
|
- 礼品卡页面需要对齐截图中的运营后台结构:白色工作台、轻量页签、克制筛选条、高密度表格。
|
||||||
|
- 表单字段需要覆盖加载、保存、取消、校验失败与成功提示等基本状态。
|
||||||
|
- 金额、流量、时间与倍率展示必须按人类可读方式格式化,不直接暴露原始后端数值。
|
||||||
|
- 最终至少完成一次构建验证,并留下结构化视觉验收与交付证据。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# admin-frontend 礼品卡管理首版交付 — 任务分解
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
- [x] 任务1:补齐礼品卡管理方案与合同产物(涉及文件:`.helloagents/plans/202604241703_admin-frontend-gift-card-management/*`;完成标准:存在需求、方案、任务与合同文件;验证方式:文件检查)
|
||||||
|
- [x] 任务2:开放礼品卡导航与路由入口(涉及文件:`admin-frontend/src/layouts/AdminLayout.vue`、`admin-frontend/src/router/index.ts`;完成标准:侧边栏可进入 `#/subscriptions/gift-cards`;验证方式:`npm run build`)
|
||||||
|
- [x] 任务3:补齐礼品卡 API、类型与工具层(涉及文件:`admin-frontend/src/api/admin.ts`、`admin-frontend/src/types/api.d.ts`、`admin-frontend/src/utils/giftCards.ts`;完成标准:前端可消费 `gift-card/*` 接口并统一完成字段映射;验证方式:`npm run build`)
|
||||||
|
- [x] 任务4:实现礼品卡管理主页面(涉及文件:`admin-frontend/src/views/subscriptions/GiftCardsView.vue`、`admin-frontend/src/views/subscriptions/GiftCardsView.scss`;完成标准:四个页签支持真实数据展示、搜索筛选、表格/统计渲染与关键操作入口;验证方式:`npm run build`)
|
||||||
|
- [x] 任务5:实现模板抽屉与兑换码生成弹层(涉及文件:`admin-frontend/src/views/subscriptions/GiftCardTemplateDrawer.vue`、`admin-frontend/src/views/subscriptions/GiftCardCodeBatchDialog.vue`;完成标准:支持模板新增/编辑与兑换码批量生成;验证方式:`npm run build`)
|
||||||
|
- [x] 任务6:完成验证与知识库同步(涉及文件:`.helloagents/CHANGELOG.md`、`.helloagents/context.md`、`.helloagents/modules/admin-frontend.md`、`.helloagents/.ralph-visual.json`、`.helloagents/.ralph-closeout.json`;完成标准:构建通过、知识库更新、交付证据写入;验证方式:命令输出 + 证据文件)
|
||||||
|
|
||||||
|
## 进度
|
||||||
|
- [x] 已确认礼品卡管理按完整四页签首版推进。
|
||||||
|
- [x] 已完成礼品卡管理真实页面、模板抽屉、兑换码生成弹层与后端接口接入。
|
||||||
|
- [x] 已完成构建验证,待输出最终交付摘要。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"completed": 5,
|
||||||
|
"failed": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"total": 5,
|
||||||
|
"done": 5,
|
||||||
|
"percent": 100,
|
||||||
|
"current": "节点管理新增 / 编辑 / 排序已完成,等待后续需求",
|
||||||
|
"updated_at": "2026-04-24 17:30:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-04-24T09:18:00.000Z",
|
||||||
|
"version": 1,
|
||||||
|
"source": "manual",
|
||||||
|
"originCommand": "generic-r2",
|
||||||
|
"verifyMode": "test-first",
|
||||||
|
"reviewerFocus": [
|
||||||
|
"节点编辑弹窗是否贴近用户截图中的高密度 Apple 化后台结构",
|
||||||
|
"不同协议切换时是否真正呈现差异化配置,而不是统一占位表单"
|
||||||
|
],
|
||||||
|
"testerFocus": [
|
||||||
|
"节点新增与编辑是否真实接入 /server/manage/save",
|
||||||
|
"节点排序是否真实接入 /server/manage/sort 并生成正确顺序 payload",
|
||||||
|
"11 种节点协议是否都拥有可用的首版动态配置表单"
|
||||||
|
],
|
||||||
|
"ui": {
|
||||||
|
"required": true,
|
||||||
|
"designContract": true,
|
||||||
|
"sourcePriority": [
|
||||||
|
"requirements.md",
|
||||||
|
".helloagents/DESIGN.md",
|
||||||
|
"hello-ui"
|
||||||
|
],
|
||||||
|
"styleAdvisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": []
|
||||||
|
},
|
||||||
|
"visualValidation": {
|
||||||
|
"required": true,
|
||||||
|
"reason": "节点管理属于截图导向的整页高密度运营工作台,新增节点弹窗与排序流程需要确认结构和状态呈现与目标截图一致。",
|
||||||
|
"screens": [
|
||||||
|
"#/nodes desktop"
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
"节点列表默认加载完成态",
|
||||||
|
"新建节点未选择协议态",
|
||||||
|
"新建节点 VLess 配置态",
|
||||||
|
"编辑排序对话框态"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advisor": {
|
||||||
|
"required": false,
|
||||||
|
"reason": "",
|
||||||
|
"focus": [],
|
||||||
|
"preferredSources": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# admin-frontend 节点管理真实工作台补全 — 实施规划
|
||||||
|
|
||||||
|
## 目标与范围
|
||||||
|
- 在现有节点列表工作台内补齐“添加节点 / 编辑节点 / 编辑排序”三条真实运营链路。
|
||||||
|
- 让节点配置从“只读列表 + 局部开关”升级为“列表 + 动态协议编辑器 + 排序对话框”的完整后台工作台。
|
||||||
|
|
||||||
|
## 架构与实现策略
|
||||||
|
- 保留现有 `NodesView` 列表骨架、统计卡片、筛选条、显隐切换、复制和删除逻辑,在此基础上接入真实新增 / 编辑 / 排序流程。
|
||||||
|
- 新增 `NodeEditorDialog.vue`:
|
||||||
|
- 负责节点新增与编辑
|
||||||
|
- 中央大弹窗布局,顶部标题与说明左对齐,协议选择器右置
|
||||||
|
- 采用“通用信息 → 动态倍率 → 协议安全层 → 传输层 → 协议专属设置”分组结构
|
||||||
|
- 新增 `NodeSortDialog.vue`:
|
||||||
|
- 负责可见顺序调整
|
||||||
|
- 使用本地排序草稿 + 上移 / 下移交互
|
||||||
|
- 保存时转换为 `[{ id, order }]` 并提交到 `/server/manage/sort`
|
||||||
|
- 新增 `src/utils/nodeEditor.ts`:
|
||||||
|
- 收敛协议选项、TLS / 传输 / 协议字段默认值
|
||||||
|
- 负责节点实体与表单模型的双向转换
|
||||||
|
- 负责把动态表单序列化为 `ServerSave` 所需 payload
|
||||||
|
- 在 `src/types/api.d.ts` 与 `src/api/admin.ts` 中补齐节点保存 / 排序的类型和接口封装。
|
||||||
|
|
||||||
|
## 完成定义
|
||||||
|
- `#/nodes` 的“添加节点”按钮能打开真实节点编辑弹窗并提交保存。
|
||||||
|
- 列表行菜单中的“编辑节点”能回填当前节点数据,并允许修改后提交。
|
||||||
|
- 不同协议切换后,表单配置区会随协议变化,不再是统一占位结构。
|
||||||
|
- “编辑排序”能打开真实排序对话框,调整顺序并保存到后台。
|
||||||
|
- 现有节点列表的显隐 / 复制 / 删除能力保持可用,不因本轮重构回归。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
- `.helloagents/plans/202604241718_admin-frontend-node-management/*`
|
||||||
|
- `admin-frontend/src/api/admin.ts`
|
||||||
|
- `admin-frontend/src/types/api.d.ts`
|
||||||
|
- `admin-frontend/src/utils/nodes.ts`
|
||||||
|
- `admin-frontend/src/utils/nodeEditor.ts`
|
||||||
|
- `admin-frontend/src/views/nodes/NodesView.vue`
|
||||||
|
- `admin-frontend/src/views/nodes/NodeEditorDialog.vue`
|
||||||
|
- `admin-frontend/src/views/nodes/NodeEditorDialog.scss`
|
||||||
|
- `admin-frontend/src/views/nodes/NodeSortDialog.vue`
|
||||||
|
- `admin-frontend/src/views/nodes/NodeSortDialog.scss`
|
||||||
|
|
||||||
|
## UI / 设计约束
|
||||||
|
- 列表页继续保持“黑色 Hero + 白色工作台”的 Apple 化后台节奏,不额外引入营销化视觉。
|
||||||
|
- 节点编辑弹窗整体贴近用户截图:标题与说明在顶部,协议切换器独立,表单区以白底轻边框输入为主,操作栏固定在底部。
|
||||||
|
- 协议差异应体现在配置结构与字段显隐上,不使用“JSON 文本编辑器”替代真实表单。
|
||||||
|
- 动态倍率、TLS、Reality、ECH、多路复用等高级项默认折叠在分组内,保证高密度但仍可读。
|
||||||
|
- 节点排序流程沿用现有套餐排序的 Apple 化列表草稿模式,避免引入沉重拖拽依赖。
|
||||||
|
|
||||||
|
## 风险与验证
|
||||||
|
- 风险 1:协议差异字段较多,如果全部堆在 `NodesView.vue` 会导致页面过大,因此节点表单与序列化逻辑必须拆到专属组件 / util。
|
||||||
|
- 风险 2:后端 `protocol_settings` 存在嵌套对象和协议差异,若序列化不统一容易导致编辑后字段丢失,因此统一通过 `src/utils/nodeEditor.ts` 生成 payload。
|
||||||
|
- 风险 3:本地环境缺少真实后台登录态时,只能做结构与构建验证,不能替代完整联调。
|
||||||
|
- 验证方式:
|
||||||
|
- `npm run build`
|
||||||
|
- 代码级结构自检 `#/nodes`
|
||||||
|
- 结构化视觉验收记录(无浏览器工具时以 code inspection 说明边界)
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
- [2026-04-24] 节点新增与编辑共用同一中央大弹窗,而不是拆成独立路由页。
|
||||||
|
- [2026-04-24] 排序沿用“本地草稿 + 上移 / 下移”的后台排序模式,不引入额外拖拽库。
|
||||||
|
- [2026-04-24] 协议配置采用“通用字段 + 动态协议块”结构,以同时满足截图风格和 11 种协议的差异表达。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# admin-frontend 节点管理真实工作台补全 — 需求
|
||||||
|
|
||||||
|
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
- 在 `admin-frontend` 的 `#/nodes` 页面内,补齐“添加节点 / 编辑节点 / 编辑排序”三条真实链路,不再保留“下一阶段接入”占位提示。
|
||||||
|
- 参考用户提供的节点弹窗截图,交付居中大弹窗式节点编辑器,并让不同节点协议按各自配置方式动态切换字段。
|
||||||
|
- 保持 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md` 定义的 Apple 化后台视觉语言,同时贴近截图中的高密度运营表单结构。
|
||||||
|
|
||||||
|
## 功能边界
|
||||||
|
- 必须实现 `#/nodes` 页面中的:
|
||||||
|
- 添加节点
|
||||||
|
- 编辑节点
|
||||||
|
- 节点排序
|
||||||
|
- 必须支持以下协议的首版真实新增 / 编辑表单:
|
||||||
|
- `Shadowsocks`
|
||||||
|
- `VMess`
|
||||||
|
- `Trojan`
|
||||||
|
- `Hysteria`
|
||||||
|
- `VLess`
|
||||||
|
- `TUIC`
|
||||||
|
- `SOCKS`
|
||||||
|
- `Naive`
|
||||||
|
- `HTTP`
|
||||||
|
- `Mieru`
|
||||||
|
- `AnyTLS`
|
||||||
|
- 表单必须覆盖通用字段:
|
||||||
|
- 节点名称
|
||||||
|
- 基础倍率
|
||||||
|
- 动态倍率开关与时间段倍率规则
|
||||||
|
- 自定义节点 ID
|
||||||
|
- 节点标签
|
||||||
|
- 权限组
|
||||||
|
- 节点地址
|
||||||
|
- 连接端口 / 服务端口
|
||||||
|
- 父级节点
|
||||||
|
- 路由组
|
||||||
|
- 前台显示 / 节点启用状态
|
||||||
|
- 必须根据协议切换不同的配置块,至少覆盖当前后端 `ServerSave` 校验与 `Server::PROTOCOL_CONFIGURATIONS` 中已定义的关键字段:
|
||||||
|
- 传输协议 / 传输层参数
|
||||||
|
- TLS / Reality / ECH / uTLS
|
||||||
|
- Hysteria 版本 / 带宽 / 混淆
|
||||||
|
- TUIC 版本 / 拥塞控制 / ALPN / UDP relay
|
||||||
|
- Shadowsocks cipher / obfs / plugin
|
||||||
|
- VLess flow / encryption
|
||||||
|
- Mieru transport / traffic pattern
|
||||||
|
- AnyTLS padding scheme
|
||||||
|
- 必须接入现有 Laravel 管理接口:
|
||||||
|
- `GET /server/manage/getNodes`
|
||||||
|
- `POST /server/manage/save`
|
||||||
|
- `POST /server/manage/sort`
|
||||||
|
- `POST /server/manage/update`
|
||||||
|
- `POST /server/manage/copy`
|
||||||
|
- `POST /server/manage/drop`
|
||||||
|
- `GET /server/group/fetch`
|
||||||
|
- `GET /server/route/fetch`
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
- 本轮不改造 Laravel 节点后端逻辑、校验规则或数据库结构。
|
||||||
|
- 本轮不接入机器管理、批量删除、批量更新、批量重置流量等二级操作。
|
||||||
|
- 本轮不实现节点健康诊断、联机测试或复杂拓扑视图。
|
||||||
|
|
||||||
|
## 技术约束
|
||||||
|
- 技术栈固定为 `Vue 3 + TypeScript + Vite + Element Plus`。
|
||||||
|
- 后端真相源以仓库内 `App\Http\Controllers\V2\Admin\Server\ManageController`、`App\Http\Requests\Admin\ServerSave` 与 `App\Models\Server` 为准。
|
||||||
|
- 节点排序继续采用当前后台 `server/manage/sort` 的顺序保存模式。
|
||||||
|
- 构建验证使用 `admin-frontend/package.json` 中已有 `npm run build`。
|
||||||
|
- 构建产物继续输出到 `public/assets/admin` 子模块。
|
||||||
|
|
||||||
|
## 质量要求
|
||||||
|
- 弹窗结构需要贴近用户截图:顶部标题说明、右上角协议选择、白色表单面板、长内容滚动区、底部固定操作栏。
|
||||||
|
- 不同协议切换时,字段分组和默认值必须清晰,不能把所有字段堆成一张无差别长表单。
|
||||||
|
- 传输层 / TLS / Reality / 多路复用等设置需要按协议语义组织,而不是只暴露原始 JSON 文本。
|
||||||
|
- 排序流程需要提供可见的顺序编辑界面和保存反馈。
|
||||||
|
- 最终至少完成一次构建验证,并留下结构化视觉验收与交付证据。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# admin-frontend 节点管理真实工作台补全 — 任务分解
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
- [x] 任务1:补齐节点管理方案与合同产物(涉及文件:`.helloagents/plans/202604241718_admin-frontend-node-management/*`;完成标准:存在需求、方案、任务与合同文件;验证方式:文件检查)
|
||||||
|
- [x] 任务2:补齐节点保存 / 排序接口、类型与表单工具层(涉及文件:`admin-frontend/src/api/admin.ts`、`admin-frontend/src/types/api.d.ts`、`admin-frontend/src/utils/nodes.ts`、`admin-frontend/src/utils/nodeEditor.ts`;完成标准:前端具备节点新增、编辑、排序的类型和序列化能力;验证方式:`npm run build`)
|
||||||
|
- [x] 任务3:实现节点新增 / 编辑弹窗(涉及文件:`admin-frontend/src/views/nodes/NodeEditorDialog.vue`、`admin-frontend/src/views/nodes/NodeEditorDialog.scss`;完成标准:支持 11 种协议的动态配置表单与保存;验证方式:`npm run build`)
|
||||||
|
- [x] 任务4:实现节点排序流程并接入列表页(涉及文件:`admin-frontend/src/views/nodes/NodeSortDialog.vue`、`admin-frontend/src/views/nodes/NodeSortDialog.scss`、`admin-frontend/src/views/nodes/NodesView.vue`;完成标准:列表页新增真实添加 / 编辑 / 排序入口,排序可保存;验证方式:`npm run build`)
|
||||||
|
- [x] 任务5:完成验证与知识库同步(涉及文件:`.helloagents/CHANGELOG.md`、`.helloagents/context.md`、`.helloagents/modules/admin-frontend.md`、`.helloagents/.ralph-visual.json`、`.helloagents/.ralph-closeout.json`;完成标准:构建通过、知识库更新、交付证据写入;验证方式:命令输出 + 证据文件)
|
||||||
|
|
||||||
|
## 进度
|
||||||
|
- [x] 已确认按“全量协议首版”推进节点管理新增 / 编辑 / 排序。
|
||||||
|
- [x] 已完成节点管理方案包、前端实现、验证与知识库同步。
|
||||||
@@ -24,3 +24,13 @@
|
|||||||
{"ts":"2026-04-24T08:26:56.881Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
|
{"ts":"2026-04-24T08:26:56.881Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
|
||||||
{"ts":"2026-04-24T08:26:56.986Z","event":"closeout_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"fix-stash-anytls-compat-filter","artifacts":[".helloagents/.ralph-closeout.json"],"details":{"requirementsCoverage":{"status":"PASS","summary":"已按保守兼容策略在 Stash 导出器中过滤未知版本与低版本的 AnyTLS,并补方案包记录。"},"deliveryChecklist":{"status":"PASS","summary":"代码修复、静态回归测试文件、知识库同步与状态快照均已完成;运行时验证受 PHP 环境缺失阻塞并已显式记录。"}}}
|
{"ts":"2026-04-24T08:26:56.986Z","event":"closeout_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"fix-stash-anytls-compat-filter","artifacts":[".helloagents/.ralph-closeout.json"],"details":{"requirementsCoverage":{"status":"PASS","summary":"已按保守兼容策略在 Stash 导出器中过滤未知版本与低版本的 AnyTLS,并补方案包记录。"},"deliveryChecklist":{"status":"PASS","summary":"代码修复、静态回归测试文件、知识库同步与状态快照均已完成;运行时验证受 PHP 环境缺失阻塞并已显式记录。"}}}
|
||||||
{"ts":"2026-04-24T08:27:02.763Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
|
{"ts":"2026-04-24T08:27:02.763Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
|
||||||
|
{"ts":"2026-04-24T08:51:26.770Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"waiting","role":"main","requiresDeliveryGate":false,"reasonCategory":"ambiguity","reason":"需要确认侧边栏在低视口高度下的滚动/折叠策略"}}
|
||||||
|
{"ts":"2026-04-24T09:07:07.749Z","event":"closeout_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"R2-generic","artifacts":[".helloagents/.ralph-closeout.json"],"details":{"requirementsCoverage":{"status":"PASS","summary":"已按用户确认方案完成侧边栏低高度修复:顶部品牌区固定,菜单区独立滚动,低高度下可访问底部入口。"},"deliveryChecklist":{"status":"PASS","summary":"已完成 AdminLayout 代码修复、npm run build 验证、Playwright 低视口滚动检查,以及 admin-frontend 模块文档与 CHANGELOG 同步。"}}}
|
||||||
|
{"ts":"2026-04-24T09:07:37.945Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
|
||||||
|
{"ts":"2026-04-24T09:18:48.741Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"waiting","role":"main","requiresDeliveryGate":false,"reasonCategory":"ambiguity","reason":"需要确认24h趋势修复范围:仅对比昨天,还是连同7天/30天一并改为上一周期对比。"}}
|
||||||
|
{"ts":"2026-04-24T09:45:03.633Z","event":"visual_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"generic-r2","artifacts":[".helloagents/.ralph-visual.json"],"details":{"reason":"礼品卡管理属于整页新增的真实后台业务页,需要确认四页签导航、模板表格、模板抽屉和统计卡片在代码结构上与目标截图及 Apple 化后台契约一致。","tooling":["code inspection","npm run build"],"screensChecked":["#/subscriptions/gift-cards desktop"],"statesChecked":["模板管理默认加载完成态","模板新增抽屉展开态","兑换码管理列表态","统计数据总览态"],"status":"PASS"}}
|
||||||
|
{"ts":"2026-04-24T09:45:03.661Z","event":"closeout_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"generic-r2","artifacts":[".helloagents/.ralph-closeout.json"],"details":{"requirementsCoverage":{"status":"PASS","summary":"礼品卡管理已交付完整四页签首版:模板管理、兑换码管理、使用记录和统计数据均已接入真实 gift-card 接口,导航入口已开放,方案包已归档。"},"deliveryChecklist":{"status":"PASS","summary":"已完成 admin-frontend 礼品卡页面、抽屉与数据层实现,执行 npm run build 通过,并同步 CHANGELOG/context/modules/archive 等知识库文件。"}}}
|
||||||
|
{"ts":"2026-04-24T09:54:43.933Z","event":"visual_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"generic-r2","artifacts":[".helloagents/.ralph-visual.json"],"details":{"reason":"节点管理属于截图导向的高密度运营工作台,本轮通过代码结构检查与构建结果确认新增、编辑、排序界面已按目标状态落地。","tooling":["code-inspection","npm run build"],"screensChecked":["#/nodes desktop"],"statesChecked":["节点列表默认加载完成态","新建节点未选择协议态","新建节点 VLess 配置态","编辑排序对话框态"],"status":"PASS"}}
|
||||||
|
{"ts":"2026-04-24T09:54:56.774Z","event":"closeout_evidence_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","skillName":"generic-r2","artifacts":[".helloagents/.ralph-closeout.json"],"details":{"requirementsCoverage":{"status":"PASS","summary":"节点管理方案包中定义的新增、编辑、排序、11 种协议动态配置、动态倍率、权限组/路由组联动与构建验证均已落地。"},"deliveryChecklist":{"status":"PASS","summary":"admin-frontend 已完成节点工作台实现、npm run build 通过、知识库同步完成,并写入视觉与收尾证据。"}}}
|
||||||
|
{"ts":"2026-04-24T09:55:06.594Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
|
||||||
|
{"ts":"2026-04-24T13:55:29.445Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"waiting","role":"main","requiresDeliveryGate":false,"reasonCategory":"ambiguity","reason":"需确认批量操作范围后再进入方案设计"}}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
{"ts":"2026-04-24T08:15:56.368Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T08_15_56_204Z-"}
|
{"ts":"2026-04-24T08:15:56.368Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T08_15_56_204Z-"}
|
||||||
{"ts":"2026-04-24T08:21:21.570Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T08_21_21_436Z-"}
|
{"ts":"2026-04-24T08:21:21.570Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T08_21_21_436Z-"}
|
||||||
{"ts":"2026-04-24T08:27:44.729Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T08_27_44_587Z-"}
|
{"ts":"2026-04-24T08:27:44.729Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T08_27_44_587Z-"}
|
||||||
|
{"ts":"2026-04-24T09:08:27.161Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T09_08_27_050Z-"}
|
||||||
|
{"ts":"2026-04-24T09:59:02.424Z","event":"verify_gate_blocked","host":"claude","source":"ralph-loop","sessionId":"2026-04-24T08-15-56-367Z-claude-u7aa37","reason":"[Ralph Loop] Verification failed: ✗ npm run build npm error Missing script: \"build\" npm error npm error To see a list of scripts, run: npm error npm run npm error A complete log of this run can be found in: C:\\Users\\xiaohuli\\AppData\\Local\\npm-cache\\_logs\\2026-04-24T09_59_02_303Z-"}
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
# 恢复快照
|
# 恢复快照
|
||||||
|
|
||||||
## 主线目标
|
## 主线目标
|
||||||
继续推进 `admin-frontend` 的订阅管理模块,完成“订单管理”首版真实工作台交付。
|
继续推进 `admin-frontend` 的节点管理模块,完成“添加节点 / 编辑节点 / 排序”真实工作台交付。
|
||||||
|
|
||||||
## 正在做什么
|
## 正在做什么
|
||||||
当前任务已完成,正在整理订单管理本轮的验证证据、知识库同步与交付摘要。
|
当前任务已完成,正在整理节点管理本轮的验证证据、知识库同步与交付摘要。
|
||||||
|
|
||||||
## 关键上下文
|
## 关键上下文
|
||||||
- 用户已在本轮选择“1”,确认按完整首版工作台实现订单管理。
|
- 用户已在本轮选择“1”,确认按“全量协议首版”推进节点管理新增 / 编辑 / 排序。
|
||||||
- 设计约束来自 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md`,订单页贴近用户截图,采用轻量筛选条 + 高密度表格 + 详情抽屉。
|
- 设计约束来自 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md`,节点弹窗贴近用户截图,采用居中大弹窗 + 顶部协议选择 + 白色高密度表单。
|
||||||
- 后端真相源为 `App\Http\Controllers\V2\Admin\OrderController`,当前可用接口为 `/order/fetch`、`/order/detail`、`/order/assign`、`/order/paid`、`/order/cancel`、`/order/update`。
|
- 后端真相源为 `App\Http\Controllers\V2\Admin\Server\ManageController`、`App\Http\Requests\Admin\ServerSave` 与 `App\Models\Server`,当前可用接口为 `/server/manage/getNodes`、`/server/manage/save`、`/server/manage/sort`、`/server/manage/update`、`/server/manage/copy`、`/server/manage/drop`。
|
||||||
- 已归档方案包:`.helloagents/archive/2026-04/202604241620_admin-frontend-order-management/`。
|
- 已在 `admin-frontend` 中新增节点动态表单工具层、中央编辑弹窗与排序对话框,并让 `#/nodes` 接入真实新增 / 编辑 / 排序流程。
|
||||||
- 已新增 `admin-frontend/src/utils/orders.ts`、`OrdersView.vue`、`OrdersView.scss`、`OrderAssignDrawer.vue` 与 `OrderDetailDrawer.vue`,并将 `/subscriptions/orders` 路由切换为真实页面。
|
- 当前方案包:`.helloagents/plans/202604241718_admin-frontend-node-management/`。
|
||||||
- `admin-frontend` 已执行 `npm run build` 并通过;构建产物已刷新 `public/assets/admin` 子模块。
|
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
当前任务已完成;如继续同一业务域,可在现有订单工作台基础上补批量操作、礼品卡管理与更完整的订单运营统计能力。
|
当前任务已完成;如继续同一业务域,可在现有节点工作台基础上补机器管理、批量操作或更深的协议高级配置。
|
||||||
|
|
||||||
## 阻塞项
|
## 阻塞项
|
||||||
(无)
|
(无)
|
||||||
|
|
||||||
## 方案
|
## 方案
|
||||||
archive/2026-04/202604241620_admin-frontend-order-management
|
plans/202604241718_admin-frontend-node-management
|
||||||
|
|
||||||
## 已标记技能
|
## 已标记技能
|
||||||
frontend-design, hello-ui, hello-verify
|
frontend-design, hello-ui, hello-verify
|
||||||
|
|||||||
@@ -9,12 +9,24 @@ import type {
|
|||||||
AdminOrderDetail,
|
AdminOrderDetail,
|
||||||
AdminOrderFetchParams,
|
AdminOrderFetchParams,
|
||||||
AdminOrderListItem,
|
AdminOrderListItem,
|
||||||
|
AdminGiftCardCodeGeneratePayload,
|
||||||
|
AdminGiftCardCodeItem,
|
||||||
|
AdminGiftCardCodeStatus,
|
||||||
|
AdminGiftCardCodeUpdatePayload,
|
||||||
|
AdminGiftCardStatistics,
|
||||||
|
AdminGiftCardTemplateItem,
|
||||||
|
AdminGiftCardTemplatePayload,
|
||||||
|
AdminGiftCardTemplateType,
|
||||||
|
AdminGiftCardUsageItem,
|
||||||
AdminKnowledgeDetail,
|
AdminKnowledgeDetail,
|
||||||
AdminKnowledgeListItem,
|
AdminKnowledgeListItem,
|
||||||
AdminKnowledgeSavePayload,
|
AdminKnowledgeSavePayload,
|
||||||
AdminNoticeItem,
|
AdminNoticeItem,
|
||||||
AdminNoticeSavePayload,
|
AdminNoticeSavePayload,
|
||||||
AdminNodeItem,
|
AdminNodeItem,
|
||||||
|
AdminNodeSavePayload,
|
||||||
|
AdminNodeRouteItem,
|
||||||
|
AdminNodeRouteSavePayload,
|
||||||
AdminNodeUpdatePayload,
|
AdminNodeUpdatePayload,
|
||||||
AdminPaymentConfigFields,
|
AdminPaymentConfigFields,
|
||||||
AdminPaymentListItem,
|
AdminPaymentListItem,
|
||||||
@@ -28,6 +40,7 @@ import type {
|
|||||||
AdminPluginItem,
|
AdminPluginItem,
|
||||||
AdminPluginTypeItem,
|
AdminPluginTypeItem,
|
||||||
AdminServerGroupItem,
|
AdminServerGroupItem,
|
||||||
|
AdminServerGroupSavePayload,
|
||||||
AdminTicketDetail,
|
AdminTicketDetail,
|
||||||
AdminTicketFetchParams,
|
AdminTicketFetchParams,
|
||||||
AdminTicketListItem,
|
AdminTicketListItem,
|
||||||
@@ -209,6 +222,91 @@ export function deleteCoupon(id: number): Promise<ApiResponse<boolean>> {
|
|||||||
return unwrapPost<boolean>('/coupon/drop', { id })
|
return unwrapPost<boolean>('/coupon/drop', { id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchGiftCardTemplates(params: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
type?: AdminGiftCardTemplateType
|
||||||
|
status?: 0 | 1
|
||||||
|
} = {}): Promise<AdminPaginationResult<AdminGiftCardTemplateItem>> {
|
||||||
|
return adminClient
|
||||||
|
.get<AdminPaginationResult<AdminGiftCardTemplateItem>>('/gift-card/templates', { params })
|
||||||
|
.then((res) => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGiftCardTemplate(payload: AdminGiftCardTemplatePayload): Promise<ApiResponse<AdminGiftCardTemplateItem>> {
|
||||||
|
return unwrapPost<AdminGiftCardTemplateItem>('/gift-card/create-template', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGiftCardTemplate(payload: AdminGiftCardTemplatePayload): Promise<ApiResponse<AdminGiftCardTemplateItem>> {
|
||||||
|
return unwrapPost<AdminGiftCardTemplateItem>('/gift-card/update-template', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGiftCardTemplate(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/gift-card/delete-template', { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGiftCardCodes(params: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
template_id?: number
|
||||||
|
batch_id?: string
|
||||||
|
status?: AdminGiftCardCodeStatus
|
||||||
|
} = {}): Promise<AdminPaginationResult<AdminGiftCardCodeItem>> {
|
||||||
|
return adminClient
|
||||||
|
.get<AdminPaginationResult<AdminGiftCardCodeItem>>('/gift-card/codes', { params })
|
||||||
|
.then((res) => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateGiftCardCodes(payload: AdminGiftCardCodeGeneratePayload): Promise<ApiResponse<{ batch_id: string, count: number, message: string }>> {
|
||||||
|
return unwrapPost<{ batch_id: string, count: number, message: string }>(
|
||||||
|
'/gift-card/generate-codes',
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGiftCardCode(id: number, action: 'disable' | 'enable'): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return unwrapPost<{ message: string }>('/gift-card/toggle-code', { id, action })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportGiftCardCodes(batchId: string): Promise<Blob> {
|
||||||
|
return adminClient
|
||||||
|
.get<Blob>('/gift-card/export-codes', {
|
||||||
|
params: { batch_id: batchId },
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
.then((res) => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGiftCardCode(payload: AdminGiftCardCodeUpdatePayload): Promise<ApiResponse<AdminGiftCardCodeItem>> {
|
||||||
|
return unwrapPost<AdminGiftCardCodeItem>('/gift-card/update-code', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGiftCardCode(id: number): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return unwrapPost<{ message: string }>('/gift-card/delete-code', { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGiftCardUsages(params: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
template_id?: number
|
||||||
|
user_id?: number
|
||||||
|
} = {}): Promise<AdminPaginationResult<AdminGiftCardUsageItem>> {
|
||||||
|
return adminClient
|
||||||
|
.get<AdminPaginationResult<AdminGiftCardUsageItem>>('/gift-card/usages', { params })
|
||||||
|
.then((res) => res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiftCardStatistics(params: {
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
} = {}): Promise<ApiResponse<AdminGiftCardStatistics>> {
|
||||||
|
return unwrap<AdminGiftCardStatistics>('/gift-card/statistics', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiftCardTypes(): Promise<ApiResponse<Record<string, string>>> {
|
||||||
|
return unwrap<Record<string, string>>('/gift-card/types')
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminConfig(key?: AdminConfigGroupKey): Promise<ApiResponse<AdminConfigMappings>> {
|
export function fetchAdminConfig(key?: AdminConfigGroupKey): Promise<ApiResponse<AdminConfigMappings>> {
|
||||||
return unwrap<AdminConfigMappings>('/config/fetch', key ? { key } : undefined)
|
return unwrap<AdminConfigMappings>('/config/fetch', key ? { key } : undefined)
|
||||||
}
|
}
|
||||||
@@ -380,14 +478,42 @@ export function getServerGroups(): Promise<ApiResponse<AdminServerGroupItem[]>>
|
|||||||
return unwrap<AdminServerGroupItem[]>('/server/group/fetch')
|
return unwrap<AdminServerGroupItem[]>('/server/group/fetch')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveServerGroup(payload: AdminServerGroupSavePayload): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/server/group/save', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteServerGroup(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/server/group/drop', { id })
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchNodes(): Promise<ApiResponse<AdminNodeItem[]>> {
|
export function fetchNodes(): Promise<ApiResponse<AdminNodeItem[]>> {
|
||||||
return unwrap<AdminNodeItem[]>('/server/manage/getNodes')
|
return unwrap<AdminNodeItem[]>('/server/manage/getNodes')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchNodeRoutes(): Promise<ApiResponse<AdminNodeRouteItem[]>> {
|
||||||
|
return unwrap<AdminNodeRouteItem[]>('/server/route/fetch')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveNodeRoute(payload: AdminNodeRouteSavePayload): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/server/route/save', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNodeRoute(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/server/route/drop', { id })
|
||||||
|
}
|
||||||
|
|
||||||
export function updateNode(payload: AdminNodeUpdatePayload): Promise<ApiResponse<boolean>> {
|
export function updateNode(payload: AdminNodeUpdatePayload): Promise<ApiResponse<boolean>> {
|
||||||
return unwrapPost<boolean>('/server/manage/update', payload as unknown as Record<string, unknown>)
|
return unwrapPost<boolean>('/server/manage/update', payload as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortNodes(payload: Array<{ id: number; order: number }>): Promise<ApiResponse<boolean>> {
|
||||||
|
return unwrapPost<boolean>('/server/manage/sort', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
export function copyNode(id: number): Promise<ApiResponse<boolean>> {
|
export function copyNode(id: number): Promise<ApiResponse<boolean>> {
|
||||||
return unwrapPost<boolean>('/server/manage/copy', { id })
|
return unwrapPost<boolean>('/server/manage/copy', { id })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const subscriptionItems: MenuItem[] = [
|
|||||||
{ index: '/subscriptions/plans', title: '套餐管理', icon: CollectionTag },
|
{ index: '/subscriptions/plans', title: '套餐管理', icon: CollectionTag },
|
||||||
{ index: '/subscriptions/orders', title: '订单管理', icon: Document },
|
{ index: '/subscriptions/orders', title: '订单管理', icon: Document },
|
||||||
{ index: '/subscriptions/coupons', title: '优惠券管理', icon: Discount },
|
{ index: '/subscriptions/coupons', title: '优惠券管理', icon: Discount },
|
||||||
{ index: '/subscriptions/gift-cards', title: '礼品卡管理', icon: Present, disabled: true, badge: '即将开放' },
|
{ index: '/subscriptions/gift-cards', title: '礼品卡管理', icon: Present },
|
||||||
]
|
]
|
||||||
|
|
||||||
const systemManagementItems: MenuItem[] = [
|
const systemManagementItems: MenuItem[] = [
|
||||||
@@ -116,19 +116,20 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElMenu
|
<div class="aside-menu-scroll">
|
||||||
:default-active="route.path"
|
<ElMenu
|
||||||
:default-openeds="['node-management', 'management', 'subscription', 'system-management']"
|
:default-active="route.path"
|
||||||
:collapse="app.sidebarCollapsed"
|
:default-openeds="['node-management', 'management', 'subscription', 'system-management']"
|
||||||
:collapse-transition="false"
|
:collapse="app.sidebarCollapsed"
|
||||||
router
|
:collapse-transition="false"
|
||||||
class="admin-menu"
|
router
|
||||||
@select="handleMenuSelect"
|
class="admin-menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.index"
|
:key="item.index"
|
||||||
:index="item.index"
|
:index="item.index"
|
||||||
>
|
>
|
||||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
@@ -203,7 +204,8 @@ onBeforeUnmount(() => {
|
|||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
|
</div>
|
||||||
</ElAside>
|
</ElAside>
|
||||||
|
|
||||||
<ElContainer class="admin-stage">
|
<ElContainer class="admin-stage">
|
||||||
@@ -251,6 +253,7 @@ onBeforeUnmount(() => {
|
|||||||
.admin-aside {
|
.admin-aside {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -261,11 +264,21 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.aside-logo {
|
.aside-logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 8px 20px;
|
padding: 12px 8px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aside-menu-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-right: 4px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.aside-mark {
|
.aside-mark {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -297,9 +310,9 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-menu {
|
.admin-menu {
|
||||||
flex: 1;
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-menu :deep(.el-menu-item) {
|
.admin-menu :deep(.el-menu-item) {
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/subscriptions/CouponsView.vue'),
|
component: () => import('@/views/subscriptions/CouponsView.vue'),
|
||||||
meta: { title: '优惠券管理', kicker: 'Coupons' },
|
meta: { title: '优惠券管理', kicker: 'Coupons' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subscriptions/gift-cards',
|
||||||
|
name: 'SubscriptionGiftCards',
|
||||||
|
component: () => import('@/views/subscriptions/GiftCardsView.vue'),
|
||||||
|
meta: { title: '礼品卡管理', kicker: 'Gift Cards' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'system/config',
|
path: 'system/config',
|
||||||
name: 'SystemConfig',
|
name: 'SystemConfig',
|
||||||
|
|||||||
Vendored
+213
-3
@@ -152,8 +152,13 @@ export interface AdminGroupOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminServerGroupItem extends AdminGroupOption {
|
export interface AdminServerGroupItem extends AdminGroupOption {
|
||||||
users_count?: number
|
users_count?: number | string | null
|
||||||
server_count?: number
|
server_count?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminServerGroupSavePayload {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminPlanOption {
|
export interface AdminPlanOption {
|
||||||
@@ -420,6 +425,147 @@ export interface AdminCouponGeneratePayload {
|
|||||||
code?: string
|
code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminGiftCardTemplateType = 1 | 2 | 3
|
||||||
|
export type AdminGiftCardCodeStatus = 0 | 1 | 2 | 3
|
||||||
|
|
||||||
|
export interface AdminGiftCardTemplateConditions {
|
||||||
|
new_user_only?: boolean
|
||||||
|
new_user_max_days?: number | null
|
||||||
|
paid_user_only?: boolean
|
||||||
|
allowed_plans?: number[]
|
||||||
|
require_invite?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardTemplateRewards {
|
||||||
|
balance?: number | null
|
||||||
|
transfer_enable?: number | null
|
||||||
|
expire_days?: number | null
|
||||||
|
device_limit?: number | null
|
||||||
|
reset_package?: boolean
|
||||||
|
plan_id?: number | null
|
||||||
|
plan_validity_days?: number | null
|
||||||
|
invite_reward_rate?: number | null
|
||||||
|
random_rewards?: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardTemplateLimits {
|
||||||
|
max_use_per_user?: number | null
|
||||||
|
cooldown_hours?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardTemplateSpecialConfig {
|
||||||
|
start_time?: number | null
|
||||||
|
end_time?: number | null
|
||||||
|
festival_bonus?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardTemplateItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
type: AdminGiftCardTemplateType
|
||||||
|
type_name: string
|
||||||
|
status: boolean | number
|
||||||
|
conditions?: AdminGiftCardTemplateConditions | null
|
||||||
|
rewards: AdminGiftCardTemplateRewards
|
||||||
|
limits?: AdminGiftCardTemplateLimits | null
|
||||||
|
special_config?: AdminGiftCardTemplateSpecialConfig | null
|
||||||
|
icon?: string | null
|
||||||
|
background_image?: string | null
|
||||||
|
theme_color?: string | null
|
||||||
|
sort?: number | null
|
||||||
|
admin_id?: number | null
|
||||||
|
created_at?: number | string | null
|
||||||
|
updated_at?: number | string | null
|
||||||
|
codes_count?: number
|
||||||
|
used_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardTemplatePayload {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
type: AdminGiftCardTemplateType
|
||||||
|
status: boolean
|
||||||
|
conditions?: AdminGiftCardTemplateConditions
|
||||||
|
rewards: AdminGiftCardTemplateRewards
|
||||||
|
limits?: AdminGiftCardTemplateLimits
|
||||||
|
special_config?: AdminGiftCardTemplateSpecialConfig
|
||||||
|
icon?: string | null
|
||||||
|
background_image?: string | null
|
||||||
|
theme_color?: string | null
|
||||||
|
sort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardCodeItem {
|
||||||
|
id: number
|
||||||
|
template_id: number
|
||||||
|
template_name: string
|
||||||
|
code: string
|
||||||
|
batch_id?: string | null
|
||||||
|
status: AdminGiftCardCodeStatus
|
||||||
|
status_name: string
|
||||||
|
user_id?: number | null
|
||||||
|
user_email?: string | null
|
||||||
|
used_at?: number | string | null
|
||||||
|
expires_at?: number | string | null
|
||||||
|
usage_count: number
|
||||||
|
max_usage: number
|
||||||
|
created_at?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardCodeGeneratePayload {
|
||||||
|
template_id: number
|
||||||
|
count: number
|
||||||
|
prefix?: string
|
||||||
|
expires_hours?: number | null
|
||||||
|
max_usage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardCodeUpdatePayload {
|
||||||
|
id: number
|
||||||
|
expires_at?: number | null
|
||||||
|
max_usage?: number
|
||||||
|
status?: AdminGiftCardCodeStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardUsageItem {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
template_name: string
|
||||||
|
user_email: string
|
||||||
|
invite_user_email?: string | null
|
||||||
|
rewards_given?: Record<string, unknown> | null
|
||||||
|
invite_rewards?: Record<string, unknown> | null
|
||||||
|
multiplier_applied?: number | null
|
||||||
|
created_at?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardStatisticsTotal {
|
||||||
|
templates_count: number
|
||||||
|
active_templates_count: number
|
||||||
|
codes_count: number
|
||||||
|
used_codes_count: number
|
||||||
|
usages_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardDailyUsage {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardTypeStat {
|
||||||
|
template_name: string
|
||||||
|
type_name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminGiftCardStatistics {
|
||||||
|
total_stats: AdminGiftCardStatisticsTotal
|
||||||
|
daily_usages: AdminGiftCardDailyUsage[]
|
||||||
|
type_stats: AdminGiftCardTypeStat[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminUserRef {
|
export interface AdminUserRef {
|
||||||
id: number
|
id: number
|
||||||
email: string
|
email: string
|
||||||
@@ -638,11 +784,44 @@ export interface AdminTrafficLogResult extends AdminPaginationResult<AdminTraffi
|
|||||||
summary: TrafficAmount
|
summary: TrafficAmount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminNodeRouteAction = 'block' | 'direct' | 'dns' | 'proxy'
|
||||||
|
|
||||||
|
export interface AdminNodeRouteItem {
|
||||||
|
id: number
|
||||||
|
remarks: string
|
||||||
|
match: string[]
|
||||||
|
action: AdminNodeRouteAction
|
||||||
|
action_value?: string | null
|
||||||
|
created_at?: number | string | null
|
||||||
|
updated_at?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNodeRouteSavePayload {
|
||||||
|
id?: number
|
||||||
|
remarks: string
|
||||||
|
match: string[]
|
||||||
|
action: AdminNodeRouteAction
|
||||||
|
action_value?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminNodeParentRef {
|
export interface AdminNodeParentRef {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminNodeType =
|
||||||
|
| 'shadowsocks'
|
||||||
|
| 'vmess'
|
||||||
|
| 'trojan'
|
||||||
|
| 'hysteria'
|
||||||
|
| 'vless'
|
||||||
|
| 'tuic'
|
||||||
|
| 'socks'
|
||||||
|
| 'naive'
|
||||||
|
| 'http'
|
||||||
|
| 'mieru'
|
||||||
|
| 'anytls'
|
||||||
|
|
||||||
export interface AdminNodeMetrics {
|
export interface AdminNodeMetrics {
|
||||||
active_connections?: number
|
active_connections?: number
|
||||||
active_users?: number
|
active_users?: number
|
||||||
@@ -650,20 +829,31 @@ export interface AdminNodeMetrics {
|
|||||||
updated_at?: number
|
updated_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminNodeRateTimeRange {
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
rate: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminNodeItem {
|
export interface AdminNodeItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: AdminNodeType | string
|
||||||
|
code?: string | null
|
||||||
host: string
|
host: string
|
||||||
port: number | string | null
|
port: number | string | null
|
||||||
server_port?: number | null
|
server_port?: number | null
|
||||||
group_ids?: Array<number | string> | null
|
group_ids?: Array<number | string> | null
|
||||||
route_ids?: Array<number | string> | null
|
route_ids?: Array<number | string> | null
|
||||||
|
tags?: string[] | null
|
||||||
show: boolean
|
show: boolean
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
parent_id?: number | null
|
parent_id?: number | null
|
||||||
rate?: number | null
|
rate?: number | null
|
||||||
|
rate_time_enable?: boolean
|
||||||
|
rate_time_ranges?: AdminNodeRateTimeRange[] | null
|
||||||
sort?: number | null
|
sort?: number | null
|
||||||
|
protocol_settings?: Record<string, unknown> | null
|
||||||
online: number
|
online: number
|
||||||
online_conn: number
|
online_conn: number
|
||||||
is_online: number
|
is_online: number
|
||||||
@@ -682,6 +872,26 @@ export interface AdminNodeUpdatePayload {
|
|||||||
machine_id?: number | null
|
machine_id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminNodeSavePayload {
|
||||||
|
id?: number
|
||||||
|
type: AdminNodeType
|
||||||
|
code?: string
|
||||||
|
name: string
|
||||||
|
group_ids?: number[]
|
||||||
|
route_ids?: number[]
|
||||||
|
parent_id?: number | null
|
||||||
|
enabled?: boolean
|
||||||
|
host: string
|
||||||
|
port: number | string
|
||||||
|
server_port: number | string
|
||||||
|
tags?: string[]
|
||||||
|
rate: number
|
||||||
|
rate_time_enable?: boolean
|
||||||
|
rate_time_ranges?: AdminNodeRateTimeRange[]
|
||||||
|
protocol_settings?: Record<string, unknown>
|
||||||
|
show?: boolean | number
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
settings?: {
|
settings?: {
|
||||||
|
|||||||
+2
@@ -15,6 +15,7 @@ declare module 'vue' {
|
|||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
@@ -43,6 +44,7 @@ declare module 'vue' {
|
|||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import type {
|
||||||
|
AdminGiftCardCodeItem,
|
||||||
|
AdminGiftCardCodeStatus,
|
||||||
|
AdminGiftCardTemplateItem,
|
||||||
|
AdminGiftCardTemplatePayload,
|
||||||
|
AdminGiftCardTemplateType,
|
||||||
|
AdminGiftCardUsageItem,
|
||||||
|
AdminPlanOption,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export type GiftCardTemplateStatusFilter = 'all' | 'enabled' | 'disabled'
|
||||||
|
export type GiftCardCodeStatusFilter = 'all' | AdminGiftCardCodeStatus
|
||||||
|
|
||||||
|
export interface GiftCardOption<T extends string | number> {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftCardStatusMeta {
|
||||||
|
label: string
|
||||||
|
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftCardTemplateFormModel {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
type: AdminGiftCardTemplateType
|
||||||
|
status: boolean
|
||||||
|
sort: number
|
||||||
|
theme_color: string
|
||||||
|
icon: string
|
||||||
|
background_image: string
|
||||||
|
balance_yuan: number | null
|
||||||
|
transfer_gb: number | null
|
||||||
|
expire_days: number | null
|
||||||
|
device_limit: number | null
|
||||||
|
reset_package: boolean
|
||||||
|
plan_id: number | null
|
||||||
|
plan_validity_days: number | null
|
||||||
|
invite_reward_rate: number | null
|
||||||
|
new_user_only: boolean
|
||||||
|
new_user_max_days: number | null
|
||||||
|
paid_user_only: boolean
|
||||||
|
require_invite: boolean
|
||||||
|
allowed_plan_ids: number[]
|
||||||
|
max_use_per_user: number | null
|
||||||
|
cooldown_hours: number | null
|
||||||
|
festival_bonus: number | null
|
||||||
|
festival_start_at: number | null
|
||||||
|
festival_end_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CNY',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DEFAULT_GIFT_CARD_TYPE_OPTIONS: Array<GiftCardOption<AdminGiftCardTemplateType>> = [
|
||||||
|
{ label: '通用礼品卡', value: 1 },
|
||||||
|
{ label: '套餐礼品卡', value: 2 },
|
||||||
|
{ label: '盲盒礼品卡', value: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const GIFT_CARD_CODE_STATUS_OPTIONS: Array<GiftCardOption<AdminGiftCardCodeStatus>> = [
|
||||||
|
{ label: '未使用', value: 0 },
|
||||||
|
{ label: '已使用', value: 1 },
|
||||||
|
{ label: '已过期', value: 2 },
|
||||||
|
{ label: '已禁用', value: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function toNumber(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const numeric = Number(value)
|
||||||
|
return Number.isFinite(numeric) ? numeric : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimestampMilliseconds(value: number | string | null | undefined): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return numeric > 1_000_000_000_000 ? numeric : numeric * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(String(value))
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanObject<T extends Record<string, unknown>>(input: T): T | undefined {
|
||||||
|
const entries = Object.entries(input).filter(([, value]) => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(entries) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowedPlanIds(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isFinite(item) && item > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGiftCardTypeOptions(typeMap?: Record<string, string> | null): Array<GiftCardOption<AdminGiftCardTemplateType>> {
|
||||||
|
if (!typeMap || Object.keys(typeMap).length === 0) {
|
||||||
|
return DEFAULT_GIFT_CARD_TYPE_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(typeMap)
|
||||||
|
.map(([value, label]) => ({
|
||||||
|
label,
|
||||||
|
value: Number(value) as AdminGiftCardTemplateType,
|
||||||
|
}))
|
||||||
|
.filter((item) => Number.isFinite(item.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGiftCardTemplateFormModel(): GiftCardTemplateFormModel {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 1,
|
||||||
|
status: true,
|
||||||
|
sort: 0,
|
||||||
|
theme_color: '#0071e3',
|
||||||
|
icon: '',
|
||||||
|
background_image: '',
|
||||||
|
balance_yuan: null,
|
||||||
|
transfer_gb: null,
|
||||||
|
expire_days: null,
|
||||||
|
device_limit: null,
|
||||||
|
reset_package: false,
|
||||||
|
plan_id: null,
|
||||||
|
plan_validity_days: null,
|
||||||
|
invite_reward_rate: null,
|
||||||
|
new_user_only: false,
|
||||||
|
new_user_max_days: 7,
|
||||||
|
paid_user_only: false,
|
||||||
|
require_invite: false,
|
||||||
|
allowed_plan_ids: [],
|
||||||
|
max_use_per_user: null,
|
||||||
|
cooldown_hours: null,
|
||||||
|
festival_bonus: null,
|
||||||
|
festival_start_at: null,
|
||||||
|
festival_end_at: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function centsToYuan(value: unknown): number | null {
|
||||||
|
const numeric = toNumber(value)
|
||||||
|
if (numeric === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Number((numeric / 100).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yuanToCents(value: unknown): number | null {
|
||||||
|
const numeric = toNumber(value)
|
||||||
|
if (numeric === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.round(numeric * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToGb(value: unknown): number | null {
|
||||||
|
const numeric = toNumber(value)
|
||||||
|
if (numeric === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Number((numeric / 1024 / 1024 / 1024).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gbToBytes(value: unknown): number | null {
|
||||||
|
const numeric = toNumber(value)
|
||||||
|
if (numeric === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.round(numeric * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGiftCardDateTime(value: number | string | null | undefined): string {
|
||||||
|
const timestamp = toTimestampMilliseconds(value)
|
||||||
|
if (timestamp === null) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return DATE_TIME_FORMATTER.format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGiftCardCurrency(value: unknown): string {
|
||||||
|
const yuan = centsToYuan(value)
|
||||||
|
return yuan === null ? '-' : CURRENCY_FORMATTER.format(yuan)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGiftCardTraffic(value: unknown): string {
|
||||||
|
const gb = bytesToGb(value)
|
||||||
|
if (gb === null) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return `${gb} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGiftCardMultiplier(value: unknown): string {
|
||||||
|
const numeric = toNumber(value)
|
||||||
|
if (numeric === null) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return `${numeric.toFixed(2)}x`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGiftCardTypeLabel(value: AdminGiftCardTemplateType, options = DEFAULT_GIFT_CARD_TYPE_OPTIONS): string {
|
||||||
|
return options.find((item) => item.value === value)?.label ?? '未知类型'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiftCardCodeStatusMeta(status: AdminGiftCardCodeStatus | null | undefined): GiftCardStatusMeta {
|
||||||
|
switch (status) {
|
||||||
|
case 0:
|
||||||
|
return { label: '未使用', tone: 'info' }
|
||||||
|
case 1:
|
||||||
|
return { label: '已使用', tone: 'success' }
|
||||||
|
case 2:
|
||||||
|
return { label: '已过期', tone: 'warning' }
|
||||||
|
case 3:
|
||||||
|
return { label: '已禁用', tone: 'danger' }
|
||||||
|
default:
|
||||||
|
return { label: '未知状态', tone: 'neutral' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiftCardTemplateRewardSummary(template: AdminGiftCardTemplateItem, plans: AdminPlanOption[] = []): string[] {
|
||||||
|
const rewards = template.rewards ?? {}
|
||||||
|
const summary: string[] = []
|
||||||
|
|
||||||
|
if (toNumber(rewards.balance) && toNumber(rewards.balance)! > 0) {
|
||||||
|
summary.push(`余额: ${formatGiftCardCurrency(rewards.balance)}`)
|
||||||
|
}
|
||||||
|
if (toNumber(rewards.transfer_enable) && toNumber(rewards.transfer_enable)! > 0) {
|
||||||
|
summary.push(`流量: ${formatGiftCardTraffic(rewards.transfer_enable)}`)
|
||||||
|
}
|
||||||
|
if (toNumber(rewards.expire_days) && toNumber(rewards.expire_days)! > 0) {
|
||||||
|
summary.push(`有效期: ${rewards.expire_days} 天`)
|
||||||
|
}
|
||||||
|
if (toNumber(rewards.device_limit) && toNumber(rewards.device_limit)! > 0) {
|
||||||
|
summary.push(`设备数: ${rewards.device_limit}`)
|
||||||
|
}
|
||||||
|
if (rewards.reset_package) {
|
||||||
|
summary.push('重置当月流量')
|
||||||
|
}
|
||||||
|
if (toNumber(rewards.plan_id)) {
|
||||||
|
const plan = plans.find((item) => item.id === Number(rewards.plan_id))
|
||||||
|
summary.push(`套餐: ${plan?.name || `#${rewards.plan_id}`}`)
|
||||||
|
if (toNumber(rewards.plan_validity_days) && toNumber(rewards.plan_validity_days)! > 0) {
|
||||||
|
summary.push(`套餐有效期: ${rewards.plan_validity_days} 天`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toNumber(rewards.invite_reward_rate) && toNumber(rewards.invite_reward_rate)! > 0) {
|
||||||
|
summary.push(`邀请奖励: ${(Number(rewards.invite_reward_rate) * 100).toFixed(0)}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.length > 0 ? summary : ['暂无奖励配置']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiftCardAvailableUsage(code: Pick<AdminGiftCardCodeItem, 'max_usage' | 'usage_count'>): number {
|
||||||
|
return Math.max(0, (code.max_usage ?? 0) - (code.usage_count ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGiftCardTemplates(
|
||||||
|
templates: AdminGiftCardTemplateItem[],
|
||||||
|
keyword: string,
|
||||||
|
type: AdminGiftCardTemplateType | 'all',
|
||||||
|
status: GiftCardTemplateStatusFilter,
|
||||||
|
): AdminGiftCardTemplateItem[] {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||||
|
|
||||||
|
return templates.filter((item) => {
|
||||||
|
const matchesKeyword = !normalizedKeyword
|
||||||
|
|| [item.name, item.description, item.type_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
|
||||||
|
|
||||||
|
const matchesType = type === 'all' || item.type === type
|
||||||
|
const matchesStatus = status === 'all'
|
||||||
|
|| (status === 'enabled' && Boolean(item.status))
|
||||||
|
|| (status === 'disabled' && !Boolean(item.status))
|
||||||
|
|
||||||
|
return matchesKeyword && matchesType && matchesStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGiftCardCodes(
|
||||||
|
codes: AdminGiftCardCodeItem[],
|
||||||
|
keyword: string,
|
||||||
|
templateId: number | 'all',
|
||||||
|
status: GiftCardCodeStatusFilter,
|
||||||
|
): AdminGiftCardCodeItem[] {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||||
|
|
||||||
|
return codes.filter((item) => {
|
||||||
|
const matchesKeyword = !normalizedKeyword
|
||||||
|
|| [item.code, item.template_name, item.batch_id, item.user_email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
|
||||||
|
|
||||||
|
const matchesTemplate = templateId === 'all' || item.template_id === templateId
|
||||||
|
const matchesStatus = status === 'all' || item.status === status
|
||||||
|
return matchesKeyword && matchesTemplate && matchesStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGiftCardUsages(usages: AdminGiftCardUsageItem[], keyword: string): AdminGiftCardUsageItem[] {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||||
|
|
||||||
|
return usages.filter((item) => {
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return [item.user_email, item.template_name, item.code, item.invite_user_email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toGiftCardTemplateFormModel(template?: AdminGiftCardTemplateItem | null): GiftCardTemplateFormModel {
|
||||||
|
const base = createGiftCardTemplateFormModel()
|
||||||
|
if (!template) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description ?? '',
|
||||||
|
type: template.type,
|
||||||
|
status: Boolean(template.status),
|
||||||
|
sort: Number(template.sort ?? 0),
|
||||||
|
theme_color: template.theme_color || '#0071e3',
|
||||||
|
icon: template.icon ?? '',
|
||||||
|
background_image: template.background_image ?? '',
|
||||||
|
balance_yuan: centsToYuan(template.rewards?.balance),
|
||||||
|
transfer_gb: bytesToGb(template.rewards?.transfer_enable),
|
||||||
|
expire_days: toNumber(template.rewards?.expire_days),
|
||||||
|
device_limit: toNumber(template.rewards?.device_limit),
|
||||||
|
reset_package: Boolean(template.rewards?.reset_package),
|
||||||
|
plan_id: toNumber(template.rewards?.plan_id),
|
||||||
|
plan_validity_days: toNumber(template.rewards?.plan_validity_days),
|
||||||
|
invite_reward_rate: toNumber(template.rewards?.invite_reward_rate),
|
||||||
|
new_user_only: Boolean(template.conditions?.new_user_only),
|
||||||
|
new_user_max_days: toNumber(template.conditions?.new_user_max_days) ?? 7,
|
||||||
|
paid_user_only: Boolean(template.conditions?.paid_user_only),
|
||||||
|
require_invite: Boolean(template.conditions?.require_invite),
|
||||||
|
allowed_plan_ids: normalizeAllowedPlanIds(template.conditions?.allowed_plans),
|
||||||
|
max_use_per_user: toNumber(template.limits?.max_use_per_user),
|
||||||
|
cooldown_hours: toNumber(template.limits?.cooldown_hours),
|
||||||
|
festival_bonus: toNumber(template.special_config?.festival_bonus),
|
||||||
|
festival_start_at: toNumber(template.special_config?.start_time),
|
||||||
|
festival_end_at: toNumber(template.special_config?.end_time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toGiftCardTemplatePayload(form: GiftCardTemplateFormModel): AdminGiftCardTemplatePayload {
|
||||||
|
const conditions = cleanObject({
|
||||||
|
new_user_only: form.new_user_only || undefined,
|
||||||
|
new_user_max_days: form.new_user_only ? toNumber(form.new_user_max_days) : undefined,
|
||||||
|
paid_user_only: form.paid_user_only || undefined,
|
||||||
|
allowed_plans: normalizeAllowedPlanIds(form.allowed_plan_ids),
|
||||||
|
require_invite: form.require_invite || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rewards = cleanObject({
|
||||||
|
balance: yuanToCents(form.balance_yuan),
|
||||||
|
transfer_enable: gbToBytes(form.transfer_gb),
|
||||||
|
expire_days: toNumber(form.expire_days),
|
||||||
|
device_limit: toNumber(form.device_limit),
|
||||||
|
reset_package: form.reset_package || undefined,
|
||||||
|
plan_id: form.type === 2 ? toNumber(form.plan_id) : undefined,
|
||||||
|
plan_validity_days: form.type === 2 ? toNumber(form.plan_validity_days) : undefined,
|
||||||
|
invite_reward_rate: toNumber(form.invite_reward_rate),
|
||||||
|
}) ?? {}
|
||||||
|
|
||||||
|
const limits = cleanObject({
|
||||||
|
max_use_per_user: toNumber(form.max_use_per_user),
|
||||||
|
cooldown_hours: toNumber(form.cooldown_hours),
|
||||||
|
})
|
||||||
|
|
||||||
|
const special_config = cleanObject({
|
||||||
|
festival_bonus: toNumber(form.festival_bonus),
|
||||||
|
start_time: toNumber(form.festival_start_at),
|
||||||
|
end_time: toNumber(form.festival_end_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: form.id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
type: form.type,
|
||||||
|
status: Boolean(form.status),
|
||||||
|
conditions,
|
||||||
|
rewards,
|
||||||
|
limits,
|
||||||
|
special_config,
|
||||||
|
icon: form.icon.trim() || null,
|
||||||
|
background_image: form.background_image.trim() || null,
|
||||||
|
theme_color: form.theme_color.trim() || '#0071e3',
|
||||||
|
sort: Math.max(0, Number(form.sort || 0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './nodeEditorOptions'
|
||||||
|
export * from './nodeEditorMapper'
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import type {
|
||||||
|
AdminNodeItem,
|
||||||
|
AdminNodeRateTimeRange,
|
||||||
|
AdminNodeSavePayload,
|
||||||
|
AdminNodeType,
|
||||||
|
} from '@/types/api'
|
||||||
|
import { createEmptyNodeForm, type NodeFormModel } from './nodeEditorOptions'
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return isRecord(value) ? value : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringValue(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberValue(value: unknown, fallback = 0): number {
|
||||||
|
const normalized = Number(value)
|
||||||
|
return Number.isFinite(normalized) ? normalized : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNullableNumber(value: unknown): number | null {
|
||||||
|
const normalized = Number(value)
|
||||||
|
return Number.isFinite(normalized) ? normalized : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBooleanValue(value: unknown, fallback = false): boolean {
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
if (typeof value === 'number') return value !== 0
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||||
|
if (['0', 'false', 'no', 'off', ''].includes(normalized)) return false
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value
|
||||||
|
.map((item) => toStringValue(item).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberArray(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return [...new Set(
|
||||||
|
value
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isFinite(item)),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMultiline(value: string): string[] {
|
||||||
|
return [...new Set(
|
||||||
|
value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitInlineList(value: string): string[] {
|
||||||
|
return [...new Set(
|
||||||
|
value
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinInlineList(value: unknown): string {
|
||||||
|
return toStringArray(value).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinMultilineList(value: unknown): string {
|
||||||
|
return toStringArray(value).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneProtocolSettings(value: unknown): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return { ...value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortNodesByOrder(nodes: AdminNodeItem[]): AdminNodeItem[] {
|
||||||
|
return [...nodes].sort((left, right) => {
|
||||||
|
const leftSort = Number(left.sort ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
const rightSort = Number(right.sort ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
if (leftSort !== rightSort) {
|
||||||
|
return leftSort - rightSort
|
||||||
|
}
|
||||||
|
return left.id - right.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveNodeOrder<T>(items: T[], fromIndex: number, direction: -1 | 1): T[] {
|
||||||
|
const targetIndex = fromIndex + direction
|
||||||
|
if (targetIndex < 0 || targetIndex >= items.length) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
const next = [...items]
|
||||||
|
const [current] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(targetIndex, 0, current)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNodeFormModel(node?: AdminNodeItem | null): NodeFormModel {
|
||||||
|
const form = createEmptyNodeForm()
|
||||||
|
if (!node) {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolSettings = cloneProtocolSettings(node.protocol_settings)
|
||||||
|
const tlsSettings = toRecord(protocolSettings.tls_settings)
|
||||||
|
const tlsObject = toRecord(protocolSettings.tls)
|
||||||
|
const realitySettings = toRecord(protocolSettings.reality_settings)
|
||||||
|
const utlsSettings = toRecord(protocolSettings.utls)
|
||||||
|
const multiplexSettings = toRecord(protocolSettings.multiplex)
|
||||||
|
const multiplexBrutal = toRecord(multiplexSettings.brutal)
|
||||||
|
const networkSettings = toRecord(protocolSettings.network_settings)
|
||||||
|
const tcpHeader = toRecord(networkSettings.header)
|
||||||
|
const tcpRequest = toRecord(tcpHeader.request)
|
||||||
|
const tcpHeaders = toRecord(tcpRequest.headers)
|
||||||
|
const wsHeaders = toRecord(networkSettings.headers)
|
||||||
|
const echFromTls = toRecord(tlsSettings.ech)
|
||||||
|
const echFromTlsObject = toRecord(tlsObject.ech)
|
||||||
|
const hysteriaObfs = toRecord(protocolSettings.obfs)
|
||||||
|
const hysteriaBandwidth = toRecord(protocolSettings.bandwidth)
|
||||||
|
const encryption = toRecord(protocolSettings.encryption)
|
||||||
|
|
||||||
|
form.id = node.id
|
||||||
|
form.originalType = (node.type as AdminNodeType) ?? ''
|
||||||
|
form.type = (node.type as AdminNodeType) ?? ''
|
||||||
|
form.rawProtocolSettings = protocolSettings
|
||||||
|
form.name = toStringValue(node.name)
|
||||||
|
form.code = toStringValue(node.code)
|
||||||
|
form.rate = toNumberValue(node.rate, 1)
|
||||||
|
form.rateTimeEnable = toBooleanValue(node.rate_time_enable)
|
||||||
|
form.rateTimeRanges = Array.isArray(node.rate_time_ranges) && node.rate_time_ranges.length > 0
|
||||||
|
? node.rate_time_ranges.map((item, index) => ({
|
||||||
|
key: `range-${node.id}-${index}`,
|
||||||
|
start: toStringValue(item.start),
|
||||||
|
end: toStringValue(item.end),
|
||||||
|
rate: toNumberValue(item.rate, 1),
|
||||||
|
}))
|
||||||
|
: createEmptyNodeForm().rateTimeRanges
|
||||||
|
form.tags = toStringArray(node.tags)
|
||||||
|
form.groupIds = toNumberArray(node.group_ids)
|
||||||
|
form.routeIds = toNumberArray(node.route_ids)
|
||||||
|
form.host = toStringValue(node.host)
|
||||||
|
form.port = toStringValue(node.port)
|
||||||
|
form.serverPort = toStringValue(node.server_port)
|
||||||
|
form.parentId = node.parent_id ?? null
|
||||||
|
form.show = toBooleanValue(node.show, true)
|
||||||
|
form.enabled = toBooleanValue(node.enabled, true)
|
||||||
|
form.tlsMode = Number(protocolSettings.tls ?? 0)
|
||||||
|
form.tlsServerName = toStringValue(tlsSettings.server_name || tlsObject.server_name)
|
||||||
|
form.tlsAllowInsecure = toBooleanValue(tlsSettings.allow_insecure ?? tlsObject.allow_insecure)
|
||||||
|
form.echEnabled = toBooleanValue(echFromTls.enabled ?? echFromTlsObject.enabled)
|
||||||
|
form.echConfig = toStringValue(echFromTls.config ?? echFromTlsObject.config)
|
||||||
|
form.echQueryServerName = toStringValue(echFromTls.query_server_name ?? echFromTlsObject.query_server_name)
|
||||||
|
form.echKey = toStringValue(echFromTls.key ?? echFromTlsObject.key)
|
||||||
|
form.utlsEnabled = toBooleanValue(utlsSettings.enabled)
|
||||||
|
form.utlsFingerprint = toStringValue(utlsSettings.fingerprint || 'chrome')
|
||||||
|
form.realityServerName = toStringValue(realitySettings.server_name)
|
||||||
|
form.realityServerPort = toStringValue(realitySettings.server_port)
|
||||||
|
form.realityPublicKey = toStringValue(realitySettings.public_key)
|
||||||
|
form.realityPrivateKey = toStringValue(realitySettings.private_key)
|
||||||
|
form.realityShortId = toStringValue(realitySettings.short_id)
|
||||||
|
form.network = toStringValue(protocolSettings.network)
|
||||||
|
form.tcpHeaderType = toStringValue(tcpHeader.type || 'none')
|
||||||
|
form.tcpRequestPath = joinMultilineList(tcpRequest.path)
|
||||||
|
form.tcpRequestHost = joinInlineList(tcpHeaders.Host)
|
||||||
|
form.wsPath = toStringValue(networkSettings.path)
|
||||||
|
form.wsHost = toStringValue(wsHeaders.Host)
|
||||||
|
form.grpcServiceName = toStringValue(networkSettings.serviceName)
|
||||||
|
form.h2Path = toStringValue(networkSettings.path)
|
||||||
|
form.h2Host = joinInlineList(networkSettings.host)
|
||||||
|
form.httpupgradePath = toStringValue(networkSettings.path)
|
||||||
|
form.httpupgradeHost = toStringValue(networkSettings.host)
|
||||||
|
form.xhttpPath = toStringValue(networkSettings.path)
|
||||||
|
form.xhttpHost = toStringValue(networkSettings.host)
|
||||||
|
form.xhttpMode = toStringValue(networkSettings.mode || 'auto')
|
||||||
|
form.xhttpExtra = networkSettings.extra ? JSON.stringify(networkSettings.extra, null, 2) : ''
|
||||||
|
form.kcpSeed = toStringValue(networkSettings.seed)
|
||||||
|
form.kcpHeaderType = toStringValue(toRecord(networkSettings.header).type || 'none')
|
||||||
|
form.shadowsocksCipher = toStringValue(protocolSettings.cipher || '2022-blake3-aes-128-gcm')
|
||||||
|
form.shadowsocksObfs = toStringValue(protocolSettings.obfs)
|
||||||
|
form.shadowsocksObfsHost = toStringValue(toRecord(protocolSettings.obfs_settings).host)
|
||||||
|
form.shadowsocksObfsPath = toStringValue(toRecord(protocolSettings.obfs_settings).path)
|
||||||
|
form.shadowsocksPlugin = toStringValue(protocolSettings.plugin)
|
||||||
|
form.shadowsocksPluginOpts = toStringValue(protocolSettings.plugin_opts)
|
||||||
|
form.vlessFlow = toStringValue(protocolSettings.flow)
|
||||||
|
form.vlessEncryptionEnabled = toBooleanValue(encryption.enabled)
|
||||||
|
form.vlessEncryption = toStringValue(encryption.encryption)
|
||||||
|
form.vlessDecryption = toStringValue(encryption.decryption)
|
||||||
|
form.hysteriaVersion = toNumberValue(protocolSettings.version, 2)
|
||||||
|
form.hysteriaUpMbps = toNullableNumber(hysteriaBandwidth.up)
|
||||||
|
form.hysteriaDownMbps = toNullableNumber(hysteriaBandwidth.down)
|
||||||
|
form.hysteriaObfsEnabled = toBooleanValue(hysteriaObfs.open)
|
||||||
|
form.hysteriaObfsType = toStringValue(hysteriaObfs.type || 'salamander')
|
||||||
|
form.hysteriaObfsPassword = toStringValue(hysteriaObfs.password)
|
||||||
|
form.hysteriaHopInterval = toNullableNumber(protocolSettings.hop_interval)
|
||||||
|
form.tuicVersion = toNullableNumber(protocolSettings.version) ?? 5
|
||||||
|
form.tuicCongestionControl = toStringValue(protocolSettings.congestion_control || 'cubic')
|
||||||
|
form.tuicAlpn = toStringArray(protocolSettings.alpn).length ? toStringArray(protocolSettings.alpn) : ['h3']
|
||||||
|
form.tuicUdpRelayMode = toStringValue(protocolSettings.udp_relay_mode || 'native')
|
||||||
|
form.mieruTransport = toStringValue(protocolSettings.transport || 'TCP')
|
||||||
|
form.mieruTrafficPattern = toStringValue(protocolSettings.traffic_pattern)
|
||||||
|
form.anytlsPaddingSchemeText = joinMultilineList(protocolSettings.padding_scheme) || form.anytlsPaddingSchemeText
|
||||||
|
form.multiplexEnabled = toBooleanValue(multiplexSettings.enabled)
|
||||||
|
form.multiplexProtocol = toStringValue(multiplexSettings.protocol || 'yamux')
|
||||||
|
form.multiplexMaxConnections = toNullableNumber(multiplexSettings.max_connections)
|
||||||
|
form.multiplexPadding = toBooleanValue(multiplexSettings.padding)
|
||||||
|
form.multiplexBrutalEnabled = toBooleanValue(multiplexBrutal.enabled)
|
||||||
|
form.multiplexBrutalUpMbps = toNullableNumber(multiplexBrutal.up_mbps)
|
||||||
|
form.multiplexBrutalDownMbps = toNullableNumber(multiplexBrutal.down_mbps)
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTlsEchPayload(form: NodeFormModel): Record<string, unknown> | undefined {
|
||||||
|
if (!form.echEnabled) return undefined
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
config: form.echConfig.trim() || undefined,
|
||||||
|
query_server_name: form.echQueryServerName.trim() || undefined,
|
||||||
|
key: form.echKey.trim() || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTlsSettingsPayload(form: NodeFormModel): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
server_name: form.tlsServerName.trim() || undefined,
|
||||||
|
allow_insecure: form.tlsAllowInsecure,
|
||||||
|
ech: buildTlsEchPayload(form),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTlsObjectPayload(form: NodeFormModel): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
server_name: form.tlsServerName.trim() || undefined,
|
||||||
|
allow_insecure: form.tlsAllowInsecure,
|
||||||
|
ech: buildTlsEchPayload(form),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRealityPayload(form: NodeFormModel): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
server_name: form.realityServerName.trim() || undefined,
|
||||||
|
server_port: form.realityServerPort.trim() ? Number(form.realityServerPort) : undefined,
|
||||||
|
public_key: form.realityPublicKey.trim() || undefined,
|
||||||
|
private_key: form.realityPrivateKey.trim() || undefined,
|
||||||
|
short_id: form.realityShortId.trim() || undefined,
|
||||||
|
allow_insecure: form.tlsAllowInsecure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUtlsPayload(form: NodeFormModel): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
enabled: form.utlsEnabled,
|
||||||
|
fingerprint: form.utlsEnabled ? form.utlsFingerprint : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMultiplexPayload(form: NodeFormModel): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
enabled: form.multiplexEnabled,
|
||||||
|
protocol: form.multiplexProtocol,
|
||||||
|
max_connections: form.multiplexEnabled ? form.multiplexMaxConnections ?? undefined : undefined,
|
||||||
|
padding: form.multiplexPadding,
|
||||||
|
brutal: {
|
||||||
|
enabled: form.multiplexBrutalEnabled,
|
||||||
|
up_mbps: form.multiplexBrutalEnabled ? form.multiplexBrutalUpMbps ?? undefined : undefined,
|
||||||
|
down_mbps: form.multiplexBrutalDownMbps ? form.multiplexBrutalDownMbps : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNetworkSettingsPayload(form: NodeFormModel): Record<string, unknown> | undefined {
|
||||||
|
switch (form.network) {
|
||||||
|
case 'tcp':
|
||||||
|
return form.tcpHeaderType === 'http'
|
||||||
|
? {
|
||||||
|
header: {
|
||||||
|
type: 'http',
|
||||||
|
request: {
|
||||||
|
path: splitMultiline(form.tcpRequestPath),
|
||||||
|
headers: { Host: splitInlineList(form.tcpRequestHost) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { header: { type: 'none' } }
|
||||||
|
case 'ws':
|
||||||
|
return {
|
||||||
|
path: form.wsPath.trim() || undefined,
|
||||||
|
headers: form.wsHost.trim() ? { Host: form.wsHost.trim() } : undefined,
|
||||||
|
}
|
||||||
|
case 'grpc':
|
||||||
|
return { serviceName: form.grpcServiceName.trim() || undefined }
|
||||||
|
case 'h2':
|
||||||
|
return {
|
||||||
|
path: form.h2Path.trim() || undefined,
|
||||||
|
host: splitInlineList(form.h2Host),
|
||||||
|
}
|
||||||
|
case 'httpupgrade':
|
||||||
|
return {
|
||||||
|
path: form.httpupgradePath.trim() || undefined,
|
||||||
|
host: form.httpupgradeHost.trim() || undefined,
|
||||||
|
}
|
||||||
|
case 'xhttp':
|
||||||
|
return {
|
||||||
|
path: form.xhttpPath.trim() || undefined,
|
||||||
|
host: form.xhttpHost.trim() || undefined,
|
||||||
|
mode: form.xhttpMode.trim() || 'auto',
|
||||||
|
extra: form.xhttpExtra.trim() ? JSON.parse(form.xhttpExtra) : undefined,
|
||||||
|
}
|
||||||
|
case 'kcp':
|
||||||
|
return {
|
||||||
|
seed: form.kcpSeed.trim() || undefined,
|
||||||
|
header: { type: form.kcpHeaderType },
|
||||||
|
}
|
||||||
|
case 'quic':
|
||||||
|
return { security: 'none' }
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRateRanges(form: NodeFormModel): AdminNodeRateTimeRange[] {
|
||||||
|
return form.rateTimeRanges
|
||||||
|
.map((item) => ({
|
||||||
|
start: item.start.trim(),
|
||||||
|
end: item.end.trim(),
|
||||||
|
rate: toNumberValue(item.rate, 1),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.start && item.end && Number.isFinite(item.rate) && item.rate > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProtocolSettings(form: NodeFormModel): Record<string, unknown> {
|
||||||
|
const preserved = form.type === form.originalType ? cloneProtocolSettings(form.rawProtocolSettings) : {}
|
||||||
|
const networkSettings = buildNetworkSettingsPayload(form)
|
||||||
|
const tlsSettings = buildTlsSettingsPayload(form)
|
||||||
|
const tlsObject = buildTlsObjectPayload(form)
|
||||||
|
const realitySettings = buildRealityPayload(form)
|
||||||
|
const utls = buildUtlsPayload(form)
|
||||||
|
const multiplex = buildMultiplexPayload(form)
|
||||||
|
|
||||||
|
switch (form.type) {
|
||||||
|
case 'shadowsocks':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
cipher: form.shadowsocksCipher,
|
||||||
|
obfs: form.shadowsocksObfs || undefined,
|
||||||
|
obfs_settings: form.shadowsocksObfs
|
||||||
|
? {
|
||||||
|
host: form.shadowsocksObfsHost.trim() || undefined,
|
||||||
|
path: form.shadowsocksObfsPath.trim() || undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
plugin: form.shadowsocksPlugin.trim() || undefined,
|
||||||
|
plugin_opts: form.shadowsocksPluginOpts.trim() || undefined,
|
||||||
|
}
|
||||||
|
case 'vmess':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
tls: form.tlsMode,
|
||||||
|
network: form.network,
|
||||||
|
network_settings: networkSettings,
|
||||||
|
tls_settings: tlsSettings,
|
||||||
|
rules: Array.isArray(preserved.rules) ? preserved.rules : [],
|
||||||
|
utls,
|
||||||
|
multiplex,
|
||||||
|
}
|
||||||
|
case 'trojan':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
tls: form.tlsMode,
|
||||||
|
network: form.network,
|
||||||
|
network_settings: networkSettings,
|
||||||
|
server_name: form.tlsMode === 1 ? form.tlsServerName.trim() || undefined : undefined,
|
||||||
|
allow_insecure: form.tlsMode === 1 ? form.tlsAllowInsecure : undefined,
|
||||||
|
tls_settings: tlsSettings,
|
||||||
|
reality_settings: form.tlsMode === 2 ? realitySettings : undefined,
|
||||||
|
utls,
|
||||||
|
multiplex,
|
||||||
|
}
|
||||||
|
case 'hysteria':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
version: form.hysteriaVersion,
|
||||||
|
bandwidth: {
|
||||||
|
up: form.hysteriaUpMbps ?? undefined,
|
||||||
|
down: form.hysteriaDownMbps ?? undefined,
|
||||||
|
},
|
||||||
|
obfs: {
|
||||||
|
open: form.hysteriaObfsEnabled,
|
||||||
|
type: form.hysteriaObfsType.trim() || 'salamander',
|
||||||
|
password: form.hysteriaObfsEnabled ? form.hysteriaObfsPassword.trim() || undefined : undefined,
|
||||||
|
},
|
||||||
|
tls: tlsObject,
|
||||||
|
hop_interval: form.hysteriaHopInterval ?? undefined,
|
||||||
|
}
|
||||||
|
case 'vless':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
tls: form.tlsMode,
|
||||||
|
tls_settings: tlsSettings,
|
||||||
|
flow: form.vlessFlow.trim() || undefined,
|
||||||
|
encryption: {
|
||||||
|
enabled: form.vlessEncryptionEnabled,
|
||||||
|
encryption: form.vlessEncryptionEnabled ? form.vlessEncryption.trim() || undefined : undefined,
|
||||||
|
decryption: form.vlessEncryptionEnabled ? form.vlessDecryption.trim() || undefined : undefined,
|
||||||
|
},
|
||||||
|
network: form.network,
|
||||||
|
network_settings: networkSettings,
|
||||||
|
reality_settings: form.tlsMode === 2 ? realitySettings : undefined,
|
||||||
|
utls,
|
||||||
|
multiplex,
|
||||||
|
}
|
||||||
|
case 'socks':
|
||||||
|
case 'naive':
|
||||||
|
case 'http':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
tls: form.tlsMode,
|
||||||
|
tls_settings: tlsSettings,
|
||||||
|
}
|
||||||
|
case 'tuic':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
version: form.tuicVersion ?? undefined,
|
||||||
|
congestion_control: form.tuicCongestionControl.trim() || 'cubic',
|
||||||
|
alpn: form.tuicAlpn.length ? form.tuicAlpn : ['h3'],
|
||||||
|
udp_relay_mode: form.tuicUdpRelayMode.trim() || 'native',
|
||||||
|
tls: tlsObject,
|
||||||
|
}
|
||||||
|
case 'mieru':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
transport: form.mieruTransport,
|
||||||
|
traffic_pattern: form.mieruTrafficPattern.trim(),
|
||||||
|
multiplex,
|
||||||
|
}
|
||||||
|
case 'anytls':
|
||||||
|
return {
|
||||||
|
...preserved,
|
||||||
|
padding_scheme: splitMultiline(form.anytlsPaddingSchemeText),
|
||||||
|
tls: tlsObject,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return preserved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNodeSavePayload(form: NodeFormModel): AdminNodeSavePayload {
|
||||||
|
return {
|
||||||
|
id: form.id,
|
||||||
|
type: form.type as AdminNodeType,
|
||||||
|
code: form.code.trim() || undefined,
|
||||||
|
name: form.name.trim(),
|
||||||
|
group_ids: [...new Set(form.groupIds)],
|
||||||
|
route_ids: [...new Set(form.routeIds)],
|
||||||
|
parent_id: form.parentId ?? undefined,
|
||||||
|
enabled: form.enabled,
|
||||||
|
host: form.host.trim(),
|
||||||
|
port: form.port.trim(),
|
||||||
|
server_port: form.serverPort.trim(),
|
||||||
|
tags: [...new Set(form.tags.map((item) => item.trim()).filter(Boolean))],
|
||||||
|
rate: Math.max(0.01, Number(form.rate) || 1),
|
||||||
|
rate_time_enable: form.rateTimeEnable,
|
||||||
|
rate_time_ranges: form.rateTimeEnable ? buildRateRanges(form) : [],
|
||||||
|
protocol_settings: buildProtocolSettings(form),
|
||||||
|
show: form.show ? 1 : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import type { AdminNodeType } from '@/types/api'
|
||||||
|
|
||||||
|
export interface NodeOption<T extends string | number = string> {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
dotColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeRateRangeForm {
|
||||||
|
key: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeFormModel {
|
||||||
|
id?: number
|
||||||
|
originalType: AdminNodeType | ''
|
||||||
|
type: AdminNodeType | ''
|
||||||
|
rawProtocolSettings: Record<string, unknown>
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
rate: number
|
||||||
|
rateTimeEnable: boolean
|
||||||
|
rateTimeRanges: NodeRateRangeForm[]
|
||||||
|
tags: string[]
|
||||||
|
groupIds: number[]
|
||||||
|
routeIds: number[]
|
||||||
|
host: string
|
||||||
|
port: string
|
||||||
|
serverPort: string
|
||||||
|
parentId: number | null
|
||||||
|
show: boolean
|
||||||
|
enabled: boolean
|
||||||
|
tlsMode: number
|
||||||
|
tlsServerName: string
|
||||||
|
tlsAllowInsecure: boolean
|
||||||
|
echEnabled: boolean
|
||||||
|
echConfig: string
|
||||||
|
echQueryServerName: string
|
||||||
|
echKey: string
|
||||||
|
utlsEnabled: boolean
|
||||||
|
utlsFingerprint: string
|
||||||
|
realityServerName: string
|
||||||
|
realityServerPort: string
|
||||||
|
realityPublicKey: string
|
||||||
|
realityPrivateKey: string
|
||||||
|
realityShortId: string
|
||||||
|
network: string
|
||||||
|
tcpHeaderType: string
|
||||||
|
tcpRequestPath: string
|
||||||
|
tcpRequestHost: string
|
||||||
|
wsPath: string
|
||||||
|
wsHost: string
|
||||||
|
grpcServiceName: string
|
||||||
|
h2Path: string
|
||||||
|
h2Host: string
|
||||||
|
httpupgradePath: string
|
||||||
|
httpupgradeHost: string
|
||||||
|
xhttpPath: string
|
||||||
|
xhttpHost: string
|
||||||
|
xhttpMode: string
|
||||||
|
xhttpExtra: string
|
||||||
|
kcpSeed: string
|
||||||
|
kcpHeaderType: string
|
||||||
|
shadowsocksCipher: string
|
||||||
|
shadowsocksObfs: string
|
||||||
|
shadowsocksObfsHost: string
|
||||||
|
shadowsocksObfsPath: string
|
||||||
|
shadowsocksPlugin: string
|
||||||
|
shadowsocksPluginOpts: string
|
||||||
|
vlessFlow: string
|
||||||
|
vlessEncryptionEnabled: boolean
|
||||||
|
vlessEncryption: string
|
||||||
|
vlessDecryption: string
|
||||||
|
hysteriaVersion: number
|
||||||
|
hysteriaUpMbps: number | null
|
||||||
|
hysteriaDownMbps: number | null
|
||||||
|
hysteriaObfsEnabled: boolean
|
||||||
|
hysteriaObfsType: string
|
||||||
|
hysteriaObfsPassword: string
|
||||||
|
hysteriaHopInterval: number | null
|
||||||
|
tuicVersion: number | null
|
||||||
|
tuicCongestionControl: string
|
||||||
|
tuicAlpn: string[]
|
||||||
|
tuicUdpRelayMode: string
|
||||||
|
mieruTransport: string
|
||||||
|
mieruTrafficPattern: string
|
||||||
|
anytlsPaddingSchemeText: string
|
||||||
|
multiplexEnabled: boolean
|
||||||
|
multiplexProtocol: string
|
||||||
|
multiplexMaxConnections: number | null
|
||||||
|
multiplexPadding: boolean
|
||||||
|
multiplexBrutalEnabled: boolean
|
||||||
|
multiplexBrutalUpMbps: number | null
|
||||||
|
multiplexBrutalDownMbps: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_PROTOCOL_OPTIONS: Array<NodeOption<AdminNodeType>> = [
|
||||||
|
{ value: 'shadowsocks', label: 'Shadowsocks', dotColor: '#44a35f' },
|
||||||
|
{ value: 'vmess', label: 'VMess', dotColor: '#d94696' },
|
||||||
|
{ value: 'trojan', label: 'Trojan', dotColor: '#f3b74f' },
|
||||||
|
{ value: 'hysteria', label: 'Hysteria', dotColor: '#5d84ff' },
|
||||||
|
{ value: 'vless', label: 'VLess', dotColor: '#111111' },
|
||||||
|
{ value: 'tuic', label: 'TUIC', dotColor: '#22c55e' },
|
||||||
|
{ value: 'socks', label: 'SOCKS', dotColor: '#3b82f6' },
|
||||||
|
{ value: 'naive', label: 'Naive', dotColor: '#8b3dff' },
|
||||||
|
{ value: 'http', label: 'HTTP', dotColor: '#ff5c2b' },
|
||||||
|
{ value: 'mieru', label: 'Mieru', dotColor: '#4caf50' },
|
||||||
|
{ value: 'anytls', label: 'AnyTLS', dotColor: '#8e59d1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_TLS_MODE_OPTIONS: Array<NodeOption<number>> = [
|
||||||
|
{ value: 0, label: '无' },
|
||||||
|
{ value: 1, label: 'TLS' },
|
||||||
|
{ value: 2, label: 'Reality' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_SIMPLE_TLS_OPTIONS: Array<NodeOption<number>> = [
|
||||||
|
{ value: 0, label: '无' },
|
||||||
|
{ value: 1, label: 'TLS' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_TRANSPORT_OPTIONS: Record<string, Array<NodeOption>> = {
|
||||||
|
vmess: [
|
||||||
|
{ value: 'tcp', label: 'TCP' },
|
||||||
|
{ value: 'ws', label: 'WebSocket' },
|
||||||
|
{ value: 'grpc', label: 'gRPC' },
|
||||||
|
{ value: 'h2', label: 'HTTP/2' },
|
||||||
|
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
|
||||||
|
{ value: 'xhttp', label: 'XHTTP' },
|
||||||
|
],
|
||||||
|
vless: [
|
||||||
|
{ value: 'tcp', label: 'TCP' },
|
||||||
|
{ value: 'ws', label: 'WebSocket' },
|
||||||
|
{ value: 'grpc', label: 'gRPC' },
|
||||||
|
{ value: 'h2', label: 'HTTP/2' },
|
||||||
|
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
|
||||||
|
{ value: 'xhttp', label: 'XHTTP' },
|
||||||
|
{ value: 'kcp', label: 'mKCP' },
|
||||||
|
{ value: 'quic', label: 'QUIC' },
|
||||||
|
],
|
||||||
|
trojan: [
|
||||||
|
{ value: 'tcp', label: 'TCP' },
|
||||||
|
{ value: 'ws', label: 'WebSocket' },
|
||||||
|
{ value: 'grpc', label: 'gRPC' },
|
||||||
|
{ value: 'h2', label: 'HTTP/2' },
|
||||||
|
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
|
||||||
|
{ value: 'xhttp', label: 'XHTTP' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_TCP_HEADER_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: 'none', label: '无头部' },
|
||||||
|
{ value: 'http', label: 'HTTP 伪装' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_TLS_FINGERPRINT_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: 'chrome', label: 'Chrome' },
|
||||||
|
{ value: 'firefox', label: 'Firefox' },
|
||||||
|
{ value: 'safari', label: 'Safari' },
|
||||||
|
{ value: 'ios', label: 'iOS' },
|
||||||
|
{ value: 'edge', label: 'Edge' },
|
||||||
|
{ value: 'qq', label: 'QQ' },
|
||||||
|
{ value: 'random', label: '随机' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_SHADOWSOCKS_CIPHER_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: 'aes-128-gcm', label: 'aes-128-gcm' },
|
||||||
|
{ value: 'aes-256-gcm', label: 'aes-256-gcm' },
|
||||||
|
{ value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
|
||||||
|
{ value: '2022-blake3-aes-128-gcm', label: '2022-blake3-aes-128-gcm' },
|
||||||
|
{ value: '2022-blake3-aes-256-gcm', label: '2022-blake3-aes-256-gcm' },
|
||||||
|
{ value: '2022-blake3-chacha20-poly1305', label: '2022-blake3-chacha20-poly1305' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_SHADOWSOCKS_OBFS_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: '', label: '无' },
|
||||||
|
{ value: 'http', label: 'HTTP' },
|
||||||
|
{ value: 'tls', label: 'TLS' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_VLESS_FLOW_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: '', label: '无' },
|
||||||
|
{ value: 'xtls-rprx-vision', label: 'xtls-rprx-vision' },
|
||||||
|
{ value: 'xtls-rprx-vision-udp443', label: 'xtls-rprx-vision-udp443' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_CONGESTION_CONTROL_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: 'cubic', label: 'cubic' },
|
||||||
|
{ value: 'bbr', label: 'bbr' },
|
||||||
|
{ value: 'new_reno', label: 'new_reno' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_UDP_RELAY_MODE_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: 'native', label: 'native' },
|
||||||
|
{ value: 'quic', label: 'quic' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_MUX_PROTOCOL_OPTIONS: Array<NodeOption> = [
|
||||||
|
{ value: 'yamux', label: 'yamux' },
|
||||||
|
{ value: 'smux', label: 'smux' },
|
||||||
|
{ value: 'h2mux', label: 'h2mux' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function createRateRange(index = 0): NodeRateRangeForm {
|
||||||
|
return {
|
||||||
|
key: `range-${Date.now()}-${index}`,
|
||||||
|
start: '',
|
||||||
|
end: '',
|
||||||
|
rate: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyNodeForm(): NodeFormModel {
|
||||||
|
return {
|
||||||
|
originalType: '',
|
||||||
|
type: '',
|
||||||
|
rawProtocolSettings: {},
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
rate: 1,
|
||||||
|
rateTimeEnable: false,
|
||||||
|
rateTimeRanges: [createRateRange()],
|
||||||
|
tags: [],
|
||||||
|
groupIds: [],
|
||||||
|
routeIds: [],
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
serverPort: '',
|
||||||
|
parentId: null,
|
||||||
|
show: true,
|
||||||
|
enabled: true,
|
||||||
|
tlsMode: 0,
|
||||||
|
tlsServerName: '',
|
||||||
|
tlsAllowInsecure: false,
|
||||||
|
echEnabled: false,
|
||||||
|
echConfig: '',
|
||||||
|
echQueryServerName: '',
|
||||||
|
echKey: '',
|
||||||
|
utlsEnabled: false,
|
||||||
|
utlsFingerprint: 'chrome',
|
||||||
|
realityServerName: '',
|
||||||
|
realityServerPort: '',
|
||||||
|
realityPublicKey: '',
|
||||||
|
realityPrivateKey: '',
|
||||||
|
realityShortId: '',
|
||||||
|
network: '',
|
||||||
|
tcpHeaderType: 'none',
|
||||||
|
tcpRequestPath: '',
|
||||||
|
tcpRequestHost: '',
|
||||||
|
wsPath: '',
|
||||||
|
wsHost: '',
|
||||||
|
grpcServiceName: '',
|
||||||
|
h2Path: '',
|
||||||
|
h2Host: '',
|
||||||
|
httpupgradePath: '',
|
||||||
|
httpupgradeHost: '',
|
||||||
|
xhttpPath: '',
|
||||||
|
xhttpHost: '',
|
||||||
|
xhttpMode: 'auto',
|
||||||
|
xhttpExtra: '',
|
||||||
|
kcpSeed: '',
|
||||||
|
kcpHeaderType: 'none',
|
||||||
|
shadowsocksCipher: '2022-blake3-aes-128-gcm',
|
||||||
|
shadowsocksObfs: '',
|
||||||
|
shadowsocksObfsHost: '',
|
||||||
|
shadowsocksObfsPath: '',
|
||||||
|
shadowsocksPlugin: '',
|
||||||
|
shadowsocksPluginOpts: '',
|
||||||
|
vlessFlow: '',
|
||||||
|
vlessEncryptionEnabled: false,
|
||||||
|
vlessEncryption: '',
|
||||||
|
vlessDecryption: '',
|
||||||
|
hysteriaVersion: 2,
|
||||||
|
hysteriaUpMbps: null,
|
||||||
|
hysteriaDownMbps: null,
|
||||||
|
hysteriaObfsEnabled: false,
|
||||||
|
hysteriaObfsType: 'salamander',
|
||||||
|
hysteriaObfsPassword: '',
|
||||||
|
hysteriaHopInterval: null,
|
||||||
|
tuicVersion: 5,
|
||||||
|
tuicCongestionControl: 'cubic',
|
||||||
|
tuicAlpn: ['h3'],
|
||||||
|
tuicUdpRelayMode: 'native',
|
||||||
|
mieruTransport: 'TCP',
|
||||||
|
mieruTrafficPattern: '',
|
||||||
|
anytlsPaddingSchemeText: [
|
||||||
|
'stop=8',
|
||||||
|
'0=30-30',
|
||||||
|
'1=100-400',
|
||||||
|
'2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000',
|
||||||
|
].join('\n'),
|
||||||
|
multiplexEnabled: false,
|
||||||
|
multiplexProtocol: 'yamux',
|
||||||
|
multiplexMaxConnections: null,
|
||||||
|
multiplexPadding: false,
|
||||||
|
multiplexBrutalEnabled: false,
|
||||||
|
multiplexBrutalUpMbps: null,
|
||||||
|
multiplexBrutalDownMbps: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNodeRateRange(): NodeRateRangeForm {
|
||||||
|
return createRateRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProtocolLabel(type: AdminNodeType | '' | string): string {
|
||||||
|
return NODE_PROTOCOL_OPTIONS.find((item) => item.value === type)?.label ?? String(type ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProtocolOptions(): Array<NodeOption<AdminNodeType>> {
|
||||||
|
return NODE_PROTOCOL_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeTlsOptions(type: AdminNodeType | '' | string): Array<NodeOption<number>> {
|
||||||
|
return type === 'vless' || type === 'trojan'
|
||||||
|
? NODE_TLS_MODE_OPTIONS
|
||||||
|
: NODE_SIMPLE_TLS_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeTransportOptions(type: AdminNodeType | '' | string): Array<NodeOption> {
|
||||||
|
return NODE_TRANSPORT_OPTIONS[type] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNodeSecurity(type: AdminNodeType | '' | string): boolean {
|
||||||
|
return ['vmess', 'vless', 'trojan', 'hysteria', 'tuic', 'anytls', 'socks', 'naive', 'http'].includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNodeTransport(type: AdminNodeType | '' | string): boolean {
|
||||||
|
return ['vmess', 'vless', 'trojan'].includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNodeMultiplex(type: AdminNodeType | '' | string): boolean {
|
||||||
|
return ['vmess', 'vless', 'trojan', 'mieru'].includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowTlsSettings(type: AdminNodeType | '' | string, tlsMode: number): boolean {
|
||||||
|
if (['hysteria', 'tuic', 'anytls'].includes(type)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (['vmess', 'socks', 'naive', 'http'].includes(type)) {
|
||||||
|
return tlsMode === 1
|
||||||
|
}
|
||||||
|
if (['vless', 'trojan'].includes(type)) {
|
||||||
|
return tlsMode === 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowRealitySettings(type: AdminNodeType | '' | string, tlsMode: number): boolean {
|
||||||
|
return ['vless', 'trojan'].includes(type) && tlsMode === 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProtocolHint(type: AdminNodeType | '' | string): string {
|
||||||
|
const hints: Record<string, string> = {
|
||||||
|
shadowsocks: '配置 cipher、混淆与 plugin,适合传统 SS 节点维护。',
|
||||||
|
vmess: '配置 TLS 与传输层参数,适合 VMess 客户端场景。',
|
||||||
|
trojan: '配置 TLS / Reality 与传输层,适合 Trojan 高兼容场景。',
|
||||||
|
hysteria: '配置版本、带宽、混淆与 TLS 信息。',
|
||||||
|
vless: '配置安全性、传输协议、Flow、Reality 与加密模式。',
|
||||||
|
tuic: '配置版本、拥塞控制、ALPN 与 UDP relay。',
|
||||||
|
socks: '配置基础 SOCKS 节点,支持可选 TLS。',
|
||||||
|
naive: '配置 NaiveProxy 基础 TLS 信息。',
|
||||||
|
http: '配置 HTTP 节点与可选 TLS。',
|
||||||
|
mieru: '配置传输方式、流量模式与多路复用。',
|
||||||
|
anytls: '配置 AnyTLS 的 TLS 信息与 Padding Scheme。',
|
||||||
|
}
|
||||||
|
return hints[type] ?? '请选择协议后继续配置。'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNodeForm(form: NodeFormModel): string | null {
|
||||||
|
if (!form.type) {
|
||||||
|
return '请选择协议类型'
|
||||||
|
}
|
||||||
|
if (form.rateTimeEnable) {
|
||||||
|
const validRanges = form.rateTimeRanges.filter((item) => item.start.trim() && item.end.trim() && Number(item.rate) > 0)
|
||||||
|
if (validRanges.length === 0) {
|
||||||
|
return '请至少填写一条有效的动态倍率规则'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.type === 'shadowsocks' && !form.shadowsocksCipher.trim()) {
|
||||||
|
return '请选择 Shadowsocks 加密方式'
|
||||||
|
}
|
||||||
|
if (['vmess', 'trojan', 'vless'].includes(form.type) && !form.network.trim()) {
|
||||||
|
return '请选择传输协议'
|
||||||
|
}
|
||||||
|
if (['vmess', 'socks', 'naive', 'http'].includes(form.type) && form.tlsMode === 1 && !form.tlsServerName.trim()) {
|
||||||
|
return '启用 TLS 时请输入服务器名称(SNI)'
|
||||||
|
}
|
||||||
|
if (['vless', 'trojan'].includes(form.type) && form.tlsMode === 2) {
|
||||||
|
if (!form.realityServerName.trim()) return 'Reality 模式下请输入服务器名称'
|
||||||
|
if (!form.realityPublicKey.trim()) return 'Reality 模式下请输入公钥'
|
||||||
|
if (!form.realityShortId.trim()) return 'Reality 模式下请输入 Short ID'
|
||||||
|
}
|
||||||
|
if (form.network === 'xhttp' && form.xhttpExtra.trim()) {
|
||||||
|
try {
|
||||||
|
JSON.parse(form.xhttpExtra)
|
||||||
|
} catch {
|
||||||
|
return 'XHTTP 额外参数必须是合法 JSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.type === 'hysteria' && form.hysteriaObfsEnabled && !form.hysteriaObfsPassword.trim()) {
|
||||||
|
return '启用 Hysteria 混淆时请输入混淆密码'
|
||||||
|
}
|
||||||
|
if (form.type === 'tuic' && form.tuicAlpn.length === 0) {
|
||||||
|
return '请至少保留一个 TUIC ALPN'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AdminServerGroupItem } from '@/types/api'
|
||||||
|
|
||||||
|
function normalizeText(value: unknown): string {
|
||||||
|
return String(value ?? '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafeCount(value: unknown): number {
|
||||||
|
const count = Number(value)
|
||||||
|
return Number.isFinite(count) ? count : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNodeGroup(group: AdminServerGroupItem): AdminServerGroupItem {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
users_count: toSafeCount(group.users_count),
|
||||||
|
server_count: toSafeCount(group.server_count),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterNodeGroups(groups: AdminServerGroupItem[], keyword: string): AdminServerGroupItem[] {
|
||||||
|
const normalizedKeyword = normalizeText(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.filter((group) => {
|
||||||
|
const searchText = [group.id, group.name, group.users_count, group.server_count]
|
||||||
|
.map((item) => String(item ?? '').trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return searchText.includes(normalizedKeyword)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeNodeGroups(groups: AdminServerGroupItem[]): {
|
||||||
|
totalUsers: number
|
||||||
|
totalServers: number
|
||||||
|
} {
|
||||||
|
return groups.reduce(
|
||||||
|
(summary, group) => ({
|
||||||
|
totalUsers: summary.totalUsers + toSafeCount(group.users_count),
|
||||||
|
totalServers: summary.totalServers + toSafeCount(group.server_count),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
totalUsers: 0,
|
||||||
|
totalServers: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ const NODE_TYPE_LABELS: Record<string, string> = {
|
|||||||
shadowsocks: 'Shadowsocks',
|
shadowsocks: 'Shadowsocks',
|
||||||
trojan: 'Trojan',
|
trojan: 'Trojan',
|
||||||
vmess: 'VMess',
|
vmess: 'VMess',
|
||||||
vless: 'VLESS',
|
vless: 'VLess',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria',
|
||||||
tuic: 'TUIC',
|
tuic: 'TUIC',
|
||||||
anytls: 'AnyTLS',
|
anytls: 'AnyTLS',
|
||||||
socks: 'SOCKS',
|
socks: 'SOCKS',
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import type {
|
||||||
|
AdminNodeItem,
|
||||||
|
AdminNodeRouteAction,
|
||||||
|
AdminNodeRouteItem,
|
||||||
|
AdminNodeRouteSavePayload,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export interface NodeRouteActionMeta {
|
||||||
|
label: string
|
||||||
|
tagType: 'danger' | 'success' | 'warning' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeRouteReferenceSummary {
|
||||||
|
count: number
|
||||||
|
names: string[]
|
||||||
|
preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeRouteFormModel {
|
||||||
|
id?: number
|
||||||
|
remarks: string
|
||||||
|
matchText: string
|
||||||
|
action: AdminNodeRouteAction
|
||||||
|
actionValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_ROUTE_ACTION_OPTIONS: Array<{
|
||||||
|
label: string
|
||||||
|
value: AdminNodeRouteAction
|
||||||
|
}> = [
|
||||||
|
{ label: '禁止访问', value: 'block' },
|
||||||
|
{ label: '指定DNS服务器进行解析', value: 'dns' },
|
||||||
|
{ label: '直连', value: 'direct' },
|
||||||
|
{ label: '转发', value: 'proxy' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROUTE_ACTION_META: Record<AdminNodeRouteAction, NodeRouteActionMeta> = {
|
||||||
|
block: { label: '禁止访问', tagType: 'danger' },
|
||||||
|
dns: { label: '指定DNS服务器进行解析', tagType: 'info' },
|
||||||
|
direct: { label: '直连', tagType: 'success' },
|
||||||
|
proxy: { label: '转发', tagType: 'warning' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatchList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(value
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将多行文本转换为路由匹配规则数组。
|
||||||
|
*
|
||||||
|
* @param value 多行输入文本。
|
||||||
|
* @returns 去重并去空后的规则数组。
|
||||||
|
*/
|
||||||
|
export function parseRouteMatchLines(value: string): string[] {
|
||||||
|
return [...new Set(value
|
||||||
|
.split(/\r?\n/g)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化后端返回的路由实体,确保表格与表单层只消费稳定结构。
|
||||||
|
*
|
||||||
|
* @param route 后端返回的原始路由对象。
|
||||||
|
* @returns 归一化后的路由实体。
|
||||||
|
*/
|
||||||
|
export function normalizeNodeRoute(route: AdminNodeRouteItem): AdminNodeRouteItem {
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
remarks: normalizeText(route.remarks),
|
||||||
|
match: normalizeMatchList(route.match),
|
||||||
|
action_value: normalizeText(route.action_value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认的路由表单模型。
|
||||||
|
*
|
||||||
|
* @returns 空表单模型。
|
||||||
|
*/
|
||||||
|
export function createEmptyNodeRouteForm(): NodeRouteFormModel {
|
||||||
|
return {
|
||||||
|
remarks: '',
|
||||||
|
matchText: '',
|
||||||
|
action: 'block',
|
||||||
|
actionValue: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将路由实体转换为编辑表单模型。
|
||||||
|
*
|
||||||
|
* @param route 当前编辑的路由;为空时返回默认模型。
|
||||||
|
* @returns 可直接绑定到表单的模型。
|
||||||
|
*/
|
||||||
|
export function toNodeRouteFormModel(route?: AdminNodeRouteItem | null): NodeRouteFormModel {
|
||||||
|
if (!route) {
|
||||||
|
return createEmptyNodeRouteForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeNodeRoute(route)
|
||||||
|
return {
|
||||||
|
id: normalized.id,
|
||||||
|
remarks: normalized.remarks,
|
||||||
|
matchText: normalized.match.join('\n'),
|
||||||
|
action: normalized.action,
|
||||||
|
actionValue: normalizeText(normalized.action_value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将表单模型序列化为保存接口需要的载荷。
|
||||||
|
*
|
||||||
|
* @param form 当前表单模型。
|
||||||
|
* @returns 可直接提交给后端的保存载荷。
|
||||||
|
*/
|
||||||
|
export function toNodeRouteSavePayload(form: NodeRouteFormModel): AdminNodeRouteSavePayload {
|
||||||
|
return {
|
||||||
|
id: form.id,
|
||||||
|
remarks: form.remarks.trim(),
|
||||||
|
match: parseRouteMatchLines(form.matchText),
|
||||||
|
action: form.action,
|
||||||
|
action_value: requiresNodeRouteActionValue(form.action)
|
||||||
|
? form.actionValue.trim()
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个动作是否要求额外填写动作值。
|
||||||
|
*
|
||||||
|
* @param action 路由动作。
|
||||||
|
* @returns 当前动作是否需要动作值。
|
||||||
|
*/
|
||||||
|
export function requiresNodeRouteActionValue(action: AdminNodeRouteAction): boolean {
|
||||||
|
return action === 'dns' || action === 'proxy'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取动作标签与颜色元信息。
|
||||||
|
*
|
||||||
|
* @param action 路由动作。
|
||||||
|
* @returns 动作标签展示元信息。
|
||||||
|
*/
|
||||||
|
export function getNodeRouteActionMeta(action: AdminNodeRouteAction): NodeRouteActionMeta {
|
||||||
|
return ROUTE_ACTION_META[action] ?? ROUTE_ACTION_META.block
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化列表中的动作值摘要。
|
||||||
|
*
|
||||||
|
* @param route 路由实体。
|
||||||
|
* @returns 面向列表展示的动作值文本。
|
||||||
|
*/
|
||||||
|
export function formatNodeRouteActionValue(route: AdminNodeRouteItem): string {
|
||||||
|
const actionValue = normalizeText(route.action_value)
|
||||||
|
switch (route.action) {
|
||||||
|
case 'dns':
|
||||||
|
return actionValue ? `DNS: ${actionValue}` : 'DNS 服务器未配置'
|
||||||
|
case 'direct':
|
||||||
|
return '直接连接'
|
||||||
|
case 'proxy':
|
||||||
|
return actionValue ? `转发: ${actionValue}` : '转发目标未配置'
|
||||||
|
case 'block':
|
||||||
|
default:
|
||||||
|
return '阻止访问'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取动作值输入框标签。
|
||||||
|
*
|
||||||
|
* @param action 路由动作。
|
||||||
|
* @returns 动作值输入标签。
|
||||||
|
*/
|
||||||
|
export function getNodeRouteActionValueLabel(action: AdminNodeRouteAction): string {
|
||||||
|
return action === 'dns' ? 'DNS服务器' : '转发目标'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取动作值输入框占位文案。
|
||||||
|
*
|
||||||
|
* @param action 路由动作。
|
||||||
|
* @returns 输入占位文案。
|
||||||
|
*/
|
||||||
|
export function getNodeRouteActionValuePlaceholder(action: AdminNodeRouteAction): string {
|
||||||
|
return action === 'dns'
|
||||||
|
? '例如 8.8.8.8 或 https://dns.google/dns-query'
|
||||||
|
: '例如 auto、proxy 或 香港出口'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为所有路由生成节点引用摘要。
|
||||||
|
*
|
||||||
|
* @param nodes 当前节点列表。
|
||||||
|
* @returns 以路由 ID 为 key 的引用摘要映射。
|
||||||
|
*/
|
||||||
|
export function buildNodeRouteReferenceMap(nodes: AdminNodeItem[]): Record<number, NodeRouteReferenceSummary> {
|
||||||
|
const map: Record<number, NodeRouteReferenceSummary> = {}
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
;(node.route_ids ?? []).forEach((routeId) => {
|
||||||
|
const normalizedId = Number(routeId)
|
||||||
|
if (!Number.isFinite(normalizedId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map[normalizedId]) {
|
||||||
|
map[normalizedId] = {
|
||||||
|
count: 0,
|
||||||
|
names: [],
|
||||||
|
preview: '未被节点引用',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map[normalizedId].count += 1
|
||||||
|
if (node.name && !map[normalizedId].names.includes(node.name)) {
|
||||||
|
map[normalizedId].names.push(node.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.values(map).forEach((summary) => {
|
||||||
|
if (summary.count === 0) {
|
||||||
|
summary.preview = '未被节点引用'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewNames = summary.names.slice(0, 2)
|
||||||
|
const more = summary.count - previewNames.length
|
||||||
|
summary.preview = more > 0
|
||||||
|
? `${previewNames.join('、')} +${more}`
|
||||||
|
: previewNames.join('、')
|
||||||
|
})
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouteSearchText(
|
||||||
|
route: AdminNodeRouteItem,
|
||||||
|
reference?: NodeRouteReferenceSummary,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
route.id,
|
||||||
|
route.remarks,
|
||||||
|
route.match.join(' '),
|
||||||
|
formatNodeRouteActionValue(route),
|
||||||
|
getNodeRouteActionMeta(route.action).label,
|
||||||
|
reference?.names.join(' '),
|
||||||
|
]
|
||||||
|
.map((item) => String(item ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按关键字过滤路由列表。
|
||||||
|
*
|
||||||
|
* @param routes 当前路由列表。
|
||||||
|
* @param keyword 搜索关键字。
|
||||||
|
* @param references 节点引用摘要映射。
|
||||||
|
* @returns 过滤后的路由列表。
|
||||||
|
*/
|
||||||
|
export function filterNodeRoutes(
|
||||||
|
routes: AdminNodeRouteItem[],
|
||||||
|
keyword: string,
|
||||||
|
references: Record<number, NodeRouteReferenceSummary>,
|
||||||
|
): AdminNodeRouteItem[] {
|
||||||
|
const normalizedKeyword = normalizeText(keyword).toLowerCase()
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes.filter((route) => buildRouteSearchText(route, references[route.id]).includes(normalizedKeyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前已被节点引用的路由数量。
|
||||||
|
*
|
||||||
|
* @param routes 路由列表。
|
||||||
|
* @param references 节点引用摘要映射。
|
||||||
|
* @returns 被引用路由数量。
|
||||||
|
*/
|
||||||
|
export function countReferencedNodeRoutes(
|
||||||
|
routes: AdminNodeRouteItem[],
|
||||||
|
references: Record<number, NodeRouteReferenceSummary>,
|
||||||
|
): number {
|
||||||
|
return routes.filter((route) => (references[route.id]?.count ?? 0) > 0).length
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
.node-editor-dialog {
|
||||||
|
.node-editor-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
max-height: min(78vh, 980px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-editor-hero,
|
||||||
|
.hero-copy__title,
|
||||||
|
.switch-row,
|
||||||
|
.switch-panel,
|
||||||
|
.node-editor-footer,
|
||||||
|
.footer-actions,
|
||||||
|
.protocol-option,
|
||||||
|
.rate-item__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-editor-hero,
|
||||||
|
.node-editor-footer,
|
||||||
|
.rate-item__footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.node-editor-form,
|
||||||
|
.form-section,
|
||||||
|
.section-head,
|
||||||
|
.form-placeholder,
|
||||||
|
.rate-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 4vw, 34px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p,
|
||||||
|
.section-head p,
|
||||||
|
.switch-row span,
|
||||||
|
.placeholder-copy,
|
||||||
|
.footer-hint,
|
||||||
|
.rate-item__footer span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-protocol {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: min(260px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-protocol__label {
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-badge {
|
||||||
|
background: #111111;
|
||||||
|
border-color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-option__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fbfbfd;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.rate-item__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width,
|
||||||
|
.full-width .el-input__wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row,
|
||||||
|
.switch-card {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row strong,
|
||||||
|
.switch-card strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-panel {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-card {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-placeholder {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fbfbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-editor-footer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.node-editor-hero,
|
||||||
|
.node-editor-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy__title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-protocol {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.form-grid,
|
||||||
|
.rate-item__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row,
|
||||||
|
.switch-card,
|
||||||
|
.node-editor-footer,
|
||||||
|
.footer-actions,
|
||||||
|
.rate-item__footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { saveNode } from '@/api/admin'
|
||||||
|
import type { AdminNodeItem, AdminNodeRouteItem, AdminNodeType, AdminServerGroupItem } from '@/types/api'
|
||||||
|
import NodeEditorProtocolSection from './NodeEditorProtocolSection.vue'
|
||||||
|
import {
|
||||||
|
createEmptyNodeForm,
|
||||||
|
createNodeRateRange,
|
||||||
|
getNodeProtocolLabel,
|
||||||
|
getNodeProtocolOptions,
|
||||||
|
toNodeFormModel,
|
||||||
|
toNodeSavePayload,
|
||||||
|
type NodeFormModel,
|
||||||
|
validateNodeForm,
|
||||||
|
} from '@/utils/nodeEditor'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
node?: AdminNodeItem | null
|
||||||
|
groups: AdminServerGroupItem[]
|
||||||
|
routes: AdminNodeRouteItem[]
|
||||||
|
nodes: AdminNodeItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
success: [message: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const form = reactive<NodeFormModel>(createEmptyNodeForm())
|
||||||
|
|
||||||
|
const protocolOptions = computed(() => getNodeProtocolOptions())
|
||||||
|
const dialogTitle = computed(() => props.mode === 'create' ? '新建节点' : '编辑节点')
|
||||||
|
const dialogDescription = computed(() => props.mode === 'create'
|
||||||
|
? '管理所有节点,包括添加、删除、编辑等操作。'
|
||||||
|
: '调整节点基础配置、协议细节与排序前置参数。')
|
||||||
|
const currentProtocolLabel = computed(() => getNodeProtocolLabel(form.type))
|
||||||
|
const parentNodeOptions = computed(() => props.nodes.filter((item) => item.id !== props.node?.id))
|
||||||
|
|
||||||
|
const rules = computed<FormRules<NodeFormModel>>(() => ({
|
||||||
|
type: [{ required: true, message: '请选择协议类型', trigger: 'change' }],
|
||||||
|
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
|
||||||
|
host: [{ required: true, message: '请输入节点地址', trigger: 'blur' }],
|
||||||
|
port: [{ required: true, message: '请输入连接端口', trigger: 'blur' }],
|
||||||
|
serverPort: [{ required: true, message: '请输入服务端口', trigger: 'blur' }],
|
||||||
|
rate: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (!Number.isFinite(Number(value)) || Number(value) <= 0) {
|
||||||
|
callback(new Error('请输入大于 0 的倍率'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncForm() {
|
||||||
|
Object.assign(form, toNodeFormModel(props.node))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProtocolDefaults(type: AdminNodeType | '') {
|
||||||
|
if (!type) {
|
||||||
|
form.network = ''
|
||||||
|
form.tlsMode = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['vmess', 'vless', 'trojan'].includes(type) && !form.network) {
|
||||||
|
form.network = 'tcp'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['vmess', 'vless', 'trojan'].includes(type)) {
|
||||||
|
form.network = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['vmess', 'vless', 'trojan', 'hysteria', 'tuic', 'anytls', 'socks', 'naive', 'http'].includes(type)) {
|
||||||
|
form.tlsMode = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'trojan' && form.tlsMode === 0) {
|
||||||
|
form.tlsMode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRateRange() {
|
||||||
|
form.rateTimeRanges.push(createNodeRateRange())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRateRange(index: number) {
|
||||||
|
if (form.rateTimeRanges.length === 1) {
|
||||||
|
form.rateTimeRanges.splice(0, 1, createNodeRateRange())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.rateTimeRanges.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const instance = formRef.value
|
||||||
|
if (!instance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await instance.validate().catch(() => false)
|
||||||
|
if (!valid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationMessage = validateNodeForm(form)
|
||||||
|
if (validationMessage) {
|
||||||
|
ElMessage.warning(validationMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await saveNode(toNodeSavePayload(form))
|
||||||
|
const message = props.mode === 'create' ? '节点已创建' : '节点已更新'
|
||||||
|
ElMessage.success(message)
|
||||||
|
emit('success', message)
|
||||||
|
closeDialog()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '节点保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.node, props.mode],
|
||||||
|
([visible]) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncForm()
|
||||||
|
applyProtocolDefaults(form.type)
|
||||||
|
nextTick(() => {
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.type,
|
||||||
|
(value) => {
|
||||||
|
applyProtocolDefaults(value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.tlsMode,
|
||||||
|
(value) => {
|
||||||
|
if (value !== 2) {
|
||||||
|
form.realityServerName = ''
|
||||||
|
form.realityServerPort = ''
|
||||||
|
form.realityPublicKey = ''
|
||||||
|
form.realityPrivateKey = ''
|
||||||
|
form.realityShortId = ''
|
||||||
|
}
|
||||||
|
if (value === 0) {
|
||||||
|
form.tlsServerName = ''
|
||||||
|
form.tlsAllowInsecure = false
|
||||||
|
form.echEnabled = false
|
||||||
|
form.echConfig = ''
|
||||||
|
form.echQueryServerName = ''
|
||||||
|
form.echKey = ''
|
||||||
|
form.utlsEnabled = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
:model-value="props.visible"
|
||||||
|
width="min(960px, calc(100vw - 24px))"
|
||||||
|
top="4vh"
|
||||||
|
destroy-on-close
|
||||||
|
class="node-editor-dialog"
|
||||||
|
@close="closeDialog"
|
||||||
|
@update:model-value="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<div class="node-editor-shell">
|
||||||
|
<header class="node-editor-hero">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div class="hero-copy__title">
|
||||||
|
<h2>{{ dialogTitle }}</h2>
|
||||||
|
<ElTag v-if="form.type" round effect="dark" class="protocol-badge">
|
||||||
|
{{ currentProtocolLabel }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<p>{{ dialogDescription }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-protocol">
|
||||||
|
<span class="hero-protocol__label">选择协议类型</span>
|
||||||
|
<ElSelect v-model="form.type" placeholder="选择协议类型">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in protocolOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
<div class="protocol-option">
|
||||||
|
<span class="protocol-option__dot" :style="{ background: option.dotColor }" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
class="node-editor-form"
|
||||||
|
>
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>基础信息</h3>
|
||||||
|
<p>先完成节点标识、地址、权限组与展示状态等通用配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<ElFormItem label="节点名称" prop="name">
|
||||||
|
<ElInput v-model="form.name" placeholder="请输入节点名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="基础倍率" prop="rate">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.rate"
|
||||||
|
:min="0.01"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
:controls="false"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="启用动态倍率" class="form-grid--full">
|
||||||
|
<div class="switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>根据时间段设置不同的倍率乘数</strong>
|
||||||
|
<span>关闭后仅使用基础倍率;开启后可配置多个倍率区间。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="form.rateTimeEnable" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="自定义节点 ID(选填)">
|
||||||
|
<ElInput v-model="form.code" placeholder="请输入自定义节点 ID" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="节点标签">
|
||||||
|
<ElSelect
|
||||||
|
v-model="form.tags"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="输入后回车添加标签"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="权限组">
|
||||||
|
<ElSelect v-model="form.groupIds" multiple collapse-tags collapse-tags-tooltip placeholder="请选择权限组">
|
||||||
|
<ElOption
|
||||||
|
v-for="group in props.groups"
|
||||||
|
:key="group.id"
|
||||||
|
:label="group.name"
|
||||||
|
:value="group.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="父级节点">
|
||||||
|
<ElSelect v-model="form.parentId" clearable placeholder="无">
|
||||||
|
<ElOption
|
||||||
|
v-for="node in parentNodeOptions"
|
||||||
|
:key="node.id"
|
||||||
|
:label="`${node.name} (#${node.id})`"
|
||||||
|
:value="node.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="节点地址" prop="host" class="form-grid--full">
|
||||||
|
<ElInput v-model="form.host" placeholder="请输入节点域名或者 IP" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="连接端口" prop="port">
|
||||||
|
<ElInput v-model="form.port" placeholder="用户连接端口" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="服务端口" prop="serverPort">
|
||||||
|
<ElInput v-model="form.serverPort" placeholder="请输入服务端口" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="路由组" class="form-grid--full">
|
||||||
|
<ElSelect v-model="form.routeIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择路由组">
|
||||||
|
<ElOption
|
||||||
|
v-for="route in props.routes"
|
||||||
|
:key="route.id"
|
||||||
|
:label="route.remarks"
|
||||||
|
:value="route.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="节点状态" class="form-grid--full">
|
||||||
|
<div class="switch-panel">
|
||||||
|
<label class="switch-card">
|
||||||
|
<div>
|
||||||
|
<strong>前台显示</strong>
|
||||||
|
<span>开启后节点会出现在可展示列表中。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="form.show" />
|
||||||
|
</label>
|
||||||
|
<label class="switch-card">
|
||||||
|
<div>
|
||||||
|
<strong>启用节点</strong>
|
||||||
|
<span>关闭后节点仍保留配置,但视为停用状态。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="form.enabled" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="form.rateTimeEnable" class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>动态倍率</h3>
|
||||||
|
<p>按时间段定义倍率规则,保存时会序列化为 `rate_time_ranges`。</p>
|
||||||
|
</div>
|
||||||
|
<ElButton @click="addRateRange">
|
||||||
|
<ElIcon><Plus /></ElIcon>
|
||||||
|
添加时间段
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rate-list">
|
||||||
|
<article
|
||||||
|
v-for="(item, index) in form.rateTimeRanges"
|
||||||
|
:key="item.key"
|
||||||
|
class="rate-item"
|
||||||
|
>
|
||||||
|
<div class="rate-item__grid">
|
||||||
|
<ElFormItem label="开始时间">
|
||||||
|
<ElTimePicker v-model="item.start" value-format="HH:mm" format="HH:mm" placeholder="09:00" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="结束时间">
|
||||||
|
<ElTimePicker v-model="item.end" value-format="HH:mm" format="HH:mm" placeholder="18:00" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="倍率">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="item.rate"
|
||||||
|
:min="0.01"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
:controls="false"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rate-item__footer">
|
||||||
|
<span>规则 {{ index + 1 }}</span>
|
||||||
|
<ElButton text type="danger" @click="removeRateRange(index)">删除</ElButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!form.type" class="form-placeholder">
|
||||||
|
<ElEmpty description="请选择协议类型后继续配置协议参数。">
|
||||||
|
<p class="placeholder-copy">不同协议会自动切换不同的安全层、传输层与专属配置项。</p>
|
||||||
|
</ElEmpty>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NodeEditorProtocolSection v-else :form="form" />
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="node-editor-footer">
|
||||||
|
<span class="footer-hint">当前协议:{{ form.type ? currentProtocolLabel : '未选择' }}</span>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ props.mode === 'create' ? '提交' : '保存修改' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" src="./NodeEditorDialog.scss"></style>
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
getNodeProtocolHint,
|
||||||
|
NODE_CONGESTION_CONTROL_OPTIONS,
|
||||||
|
NODE_MUX_PROTOCOL_OPTIONS,
|
||||||
|
NODE_SHADOWSOCKS_CIPHER_OPTIONS,
|
||||||
|
NODE_SHADOWSOCKS_OBFS_OPTIONS,
|
||||||
|
NODE_TCP_HEADER_OPTIONS,
|
||||||
|
NODE_TLS_FINGERPRINT_OPTIONS,
|
||||||
|
NODE_UDP_RELAY_MODE_OPTIONS,
|
||||||
|
NODE_VLESS_FLOW_OPTIONS,
|
||||||
|
shouldShowRealitySettings,
|
||||||
|
shouldShowTlsSettings,
|
||||||
|
supportsNodeMultiplex,
|
||||||
|
supportsNodeSecurity,
|
||||||
|
supportsNodeTransport,
|
||||||
|
getNodeTlsOptions,
|
||||||
|
getNodeTransportOptions,
|
||||||
|
type NodeFormModel,
|
||||||
|
} from '@/utils/nodeEditor'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: NodeFormModel
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const transportOptions = computed(() => getNodeTransportOptions(props.form.type))
|
||||||
|
const tlsOptions = computed(() => getNodeTlsOptions(props.form.type))
|
||||||
|
const showSecuritySection = computed(() => supportsNodeSecurity(props.form.type))
|
||||||
|
const showTransportSection = computed(() => supportsNodeTransport(props.form.type))
|
||||||
|
const showMultiplexSection = computed(() => supportsNodeMultiplex(props.form.type))
|
||||||
|
const showTlsSection = computed(() => shouldShowTlsSettings(props.form.type, props.form.tlsMode))
|
||||||
|
const showRealitySection = computed(() => shouldShowRealitySettings(props.form.type, props.form.tlsMode))
|
||||||
|
const currentProtocolHint = computed(() => getNodeProtocolHint(props.form.type))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-if="showSecuritySection" class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>安全层</h3>
|
||||||
|
<p>根据协议切换 TLS / Reality / ECH / uTLS 等安全配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<ElFormItem
|
||||||
|
v-if="['vmess', 'vless', 'trojan', 'socks', 'naive', 'http'].includes(props.form.type)"
|
||||||
|
label="安全性"
|
||||||
|
>
|
||||||
|
<ElSelect v-model="props.form.tlsMode" placeholder="请选择安全性">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in tlsOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem
|
||||||
|
v-if="['hysteria', 'tuic', 'anytls'].includes(props.form.type)"
|
||||||
|
label="服务器名称(SNI)"
|
||||||
|
>
|
||||||
|
<ElInput v-model="props.form.tlsServerName" placeholder="example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="showTlsSection" label="服务器名称(SNI)">
|
||||||
|
<ElInput v-model="props.form.tlsServerName" placeholder="example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="showTlsSection || ['hysteria', 'tuic', 'anytls'].includes(props.form.type)" label="允许不安全连接">
|
||||||
|
<ElSwitch v-model="props.form.tlsAllowInsecure" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="showTlsSection || ['hysteria', 'tuic', 'anytls'].includes(props.form.type)" label="启用 ECH" class="form-grid--full">
|
||||||
|
<div class="switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>Encrypted Client Hello</strong>
|
||||||
|
<span>用于支持 ECH 的 TLS 场景;关闭时不会写入 ECH 配置。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="props.form.echEnabled" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="props.form.echEnabled">
|
||||||
|
<ElFormItem label="ECH Config" class="form-grid--full">
|
||||||
|
<ElInput
|
||||||
|
v-model="props.form.echConfig"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="粘贴 ECH Config"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="ECH 查询域名">
|
||||||
|
<ElInput v-model="props.form.echQueryServerName" placeholder="ech.example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="ECH Key(可选)">
|
||||||
|
<ElInput v-model="props.form.echKey" placeholder="仅服务端维护时填写" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElFormItem
|
||||||
|
v-if="showTlsSection || showRealitySection"
|
||||||
|
label="启用 uTLS"
|
||||||
|
class="form-grid--full"
|
||||||
|
>
|
||||||
|
<div class="switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>uTLS 指纹伪装</strong>
|
||||||
|
<span>适用于需要模拟客户端指纹的连接场景。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="props.form.utlsEnabled" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="props.form.utlsEnabled" label="uTLS 指纹">
|
||||||
|
<ElSelect v-model="props.form.utlsFingerprint" placeholder="请选择指纹">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_TLS_FINGERPRINT_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="showRealitySection">
|
||||||
|
<ElFormItem label="Reality 服务器名称">
|
||||||
|
<ElInput v-model="props.form.realityServerName" placeholder="www.cloudflare.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Reality 服务器端口">
|
||||||
|
<ElInput v-model="props.form.realityServerPort" placeholder="443" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Reality 公钥" class="form-grid--full">
|
||||||
|
<ElInput v-model="props.form.realityPublicKey" placeholder="请输入公钥" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Reality 私钥" class="form-grid--full">
|
||||||
|
<ElInput v-model="props.form.realityPrivateKey" placeholder="仅服务端维护时填写" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Reality Short ID">
|
||||||
|
<ElInput v-model="props.form.realityShortId" placeholder="请输入 Short ID" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showTransportSection" class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>传输层</h3>
|
||||||
|
<p>按不同传输协议切换对应字段,避免把所有参数堆到同一层。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<ElFormItem label="传输协议">
|
||||||
|
<ElSelect v-model="props.form.network" placeholder="请选择传输协议">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in transportOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="props.form.network === 'tcp'" label="TCP 头部类型">
|
||||||
|
<ElSelect v-model="props.form.tcpHeaderType" placeholder="请选择头部类型">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_TCP_HEADER_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="props.form.network === 'tcp' && props.form.tcpHeaderType === 'http'">
|
||||||
|
<ElFormItem label="请求路径" class="form-grid--full">
|
||||||
|
<ElInput
|
||||||
|
v-model="props.form.tcpRequestPath"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 4 }"
|
||||||
|
placeholder="每行一个 path,例如: /api /ws"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Host 列表" class="form-grid--full">
|
||||||
|
<ElInput v-model="props.form.tcpRequestHost" placeholder="多个 Host 用逗号分隔" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="props.form.network === 'ws'">
|
||||||
|
<ElFormItem label="路径">
|
||||||
|
<ElInput v-model="props.form.wsPath" placeholder="/ws" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Host">
|
||||||
|
<ElInput v-model="props.form.wsHost" placeholder="ws.example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElFormItem v-if="props.form.network === 'grpc'" label="Service Name">
|
||||||
|
<ElInput v-model="props.form.grpcServiceName" placeholder="grpc-service" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="props.form.network === 'h2'">
|
||||||
|
<ElFormItem label="路径">
|
||||||
|
<ElInput v-model="props.form.h2Path" placeholder="/" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Host 列表">
|
||||||
|
<ElInput v-model="props.form.h2Host" placeholder="多个 Host 用逗号分隔" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="props.form.network === 'httpupgrade'">
|
||||||
|
<ElFormItem label="路径">
|
||||||
|
<ElInput v-model="props.form.httpupgradePath" placeholder="/upgrade" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Host">
|
||||||
|
<ElInput v-model="props.form.httpupgradeHost" placeholder="upgrade.example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="props.form.network === 'xhttp'">
|
||||||
|
<ElFormItem label="路径">
|
||||||
|
<ElInput v-model="props.form.xhttpPath" placeholder="/connect" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Host">
|
||||||
|
<ElInput v-model="props.form.xhttpHost" placeholder="xhttp.example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="模式">
|
||||||
|
<ElInput v-model="props.form.xhttpMode" placeholder="auto" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="额外参数 JSON" class="form-grid--full">
|
||||||
|
<ElInput
|
||||||
|
v-model="props.form.xhttpExtra"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder='例如:{ "mode": "stream-one" }'
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="props.form.network === 'kcp'">
|
||||||
|
<ElFormItem label="Seed">
|
||||||
|
<ElInput v-model="props.form.kcpSeed" placeholder="请输入 mKCP seed" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="KCP 头部类型">
|
||||||
|
<ElSelect v-model="props.form.kcpHeaderType" placeholder="请选择头部类型">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_TCP_HEADER_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>协议配置</h3>
|
||||||
|
<p>{{ currentProtocolHint }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<template v-if="props.form.type === 'shadowsocks'">
|
||||||
|
<ElFormItem label="加密方式">
|
||||||
|
<ElSelect v-model="props.form.shadowsocksCipher" placeholder="请选择加密方式">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_SHADOWSOCKS_CIPHER_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="混淆方式">
|
||||||
|
<ElSelect v-model="props.form.shadowsocksObfs" placeholder="请选择混淆">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_SHADOWSOCKS_OBFS_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="props.form.shadowsocksObfs" label="混淆 Host">
|
||||||
|
<ElInput v-model="props.form.shadowsocksObfsHost" placeholder="obfs host" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="props.form.shadowsocksObfs" label="混淆路径">
|
||||||
|
<ElInput v-model="props.form.shadowsocksObfsPath" placeholder="/path" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Plugin">
|
||||||
|
<ElInput v-model="props.form.shadowsocksPlugin" placeholder="v2ray-plugin" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Plugin 参数">
|
||||||
|
<ElInput v-model="props.form.shadowsocksPluginOpts" placeholder="server;tls;host=example.com" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="props.form.type === 'vless'">
|
||||||
|
<ElFormItem label="Flow">
|
||||||
|
<ElSelect v-model="props.form.vlessFlow" placeholder="请选择 Flow">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_VLESS_FLOW_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="启用自定义加密" class="form-grid--full">
|
||||||
|
<div class="switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>VLess Encryption</strong>
|
||||||
|
<span>适用于需要额外加解密配置的场景。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="props.form.vlessEncryptionEnabled" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="props.form.vlessEncryptionEnabled" label="客户端公钥">
|
||||||
|
<ElInput v-model="props.form.vlessEncryption" placeholder="encryption key" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="props.form.vlessEncryptionEnabled" label="服务端私钥">
|
||||||
|
<ElInput v-model="props.form.vlessDecryption" placeholder="decryption key" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="props.form.type === 'hysteria'">
|
||||||
|
<ElFormItem label="协议版本">
|
||||||
|
<ElSelect v-model="props.form.hysteriaVersion" placeholder="请选择版本">
|
||||||
|
<ElOption :value="1" label="Hysteria 1" />
|
||||||
|
<ElOption :value="2" label="Hysteria 2" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="上行带宽 Mbps">
|
||||||
|
<ElInputNumber v-model="props.form.hysteriaUpMbps" :min="0" :controls="false" class="full-width" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="下行带宽 Mbps">
|
||||||
|
<ElInputNumber v-model="props.form.hysteriaDownMbps" :min="0" :controls="false" class="full-width" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="端口跳跃间隔(秒)">
|
||||||
|
<ElInputNumber v-model="props.form.hysteriaHopInterval" :min="0" :controls="false" class="full-width" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="启用混淆" class="form-grid--full">
|
||||||
|
<div class="switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>Obfs</strong>
|
||||||
|
<span>Hysteria 2 默认推荐 Salamander;开启后需提供密码。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="props.form.hysteriaObfsEnabled" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="props.form.hysteriaObfsEnabled" label="混淆类型">
|
||||||
|
<ElInput v-model="props.form.hysteriaObfsType" placeholder="salamander" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="props.form.hysteriaObfsEnabled" label="混淆密码">
|
||||||
|
<ElInput v-model="props.form.hysteriaObfsPassword" placeholder="请输入混淆密码" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="props.form.type === 'tuic'">
|
||||||
|
<ElFormItem label="协议版本">
|
||||||
|
<ElInputNumber v-model="props.form.tuicVersion" :min="1" :controls="false" class="full-width" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="拥塞控制">
|
||||||
|
<ElSelect v-model="props.form.tuicCongestionControl" placeholder="请选择拥塞控制">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_CONGESTION_CONTROL_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="UDP Relay Mode">
|
||||||
|
<ElSelect v-model="props.form.tuicUdpRelayMode" placeholder="请选择模式">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_UDP_RELAY_MODE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="ALPN">
|
||||||
|
<ElSelect
|
||||||
|
v-model="props.form.tuicAlpn"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
placeholder="输入后回车添加 ALPN"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="props.form.type === 'mieru'">
|
||||||
|
<ElFormItem label="传输方式">
|
||||||
|
<ElSelect v-model="props.form.mieruTransport" placeholder="请选择传输方式">
|
||||||
|
<ElOption value="TCP" label="TCP" />
|
||||||
|
<ElOption value="UDP" label="UDP" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Traffic Pattern">
|
||||||
|
<ElInput v-model="props.form.mieruTrafficPattern" placeholder="例如:steady" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="props.form.type === 'anytls'">
|
||||||
|
<ElFormItem label="Padding Scheme" class="form-grid--full">
|
||||||
|
<ElInput
|
||||||
|
v-model="props.form.anytlsPaddingSchemeText"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||||
|
placeholder="每行一条 padding scheme"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showMultiplexSection" class="form-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>多路复用</h3>
|
||||||
|
<p>对支持的协议开放多路复用与 Brutal 加速配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<ElFormItem label="启用多路复用" class="form-grid--full">
|
||||||
|
<div class="switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>Multiplex</strong>
|
||||||
|
<span>适用于 VLess / VMess / Trojan / Mieru 的复用场景。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="props.form.multiplexEnabled" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="props.form.multiplexEnabled">
|
||||||
|
<ElFormItem label="复用协议">
|
||||||
|
<ElSelect v-model="props.form.multiplexProtocol" placeholder="请选择协议">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_MUX_PROTOCOL_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="最大连接数">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="props.form.multiplexMaxConnections"
|
||||||
|
:min="1"
|
||||||
|
:controls="false"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="填充">
|
||||||
|
<ElSwitch v-model="props.form.multiplexPadding" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="启用 Brutal">
|
||||||
|
<ElSwitch v-model="props.form.multiplexBrutalEnabled" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="props.form.multiplexBrutalEnabled">
|
||||||
|
<ElFormItem label="Brutal 上行 Mbps">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="props.form.multiplexBrutalUpMbps"
|
||||||
|
:min="1"
|
||||||
|
:controls="false"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="Brutal 下行 Mbps">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="props.form.multiplexBrutalDownMbps"
|
||||||
|
:min="1"
|
||||||
|
:controls="false"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { saveServerGroup } from '@/api/admin'
|
||||||
|
import type { AdminServerGroupItem } from '@/types/api'
|
||||||
|
|
||||||
|
type DialogMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
interface NodeGroupFormModel {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
mode: DialogMode
|
||||||
|
group: AdminServerGroupItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
success: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const form = reactive<NodeGroupFormModel>({
|
||||||
|
name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => props.mode === 'create' ? '添加权限组' : '编辑权限组')
|
||||||
|
const dialogDescription = computed(() => props.mode === 'create'
|
||||||
|
? '创建新的权限组,供节点、套餐与用户权限分配使用。'
|
||||||
|
: '修改权限组信息,更新后会立即影响后台显示。')
|
||||||
|
|
||||||
|
const rules = computed<FormRules<NodeGroupFormModel>>(() => ({
|
||||||
|
name: [{ required: true, message: '请输入权限组名称', trigger: 'blur' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const instance = formRef.value
|
||||||
|
if (!instance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await instance.validate().catch(() => false)
|
||||||
|
if (!valid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await saveServerGroup({
|
||||||
|
id: props.mode === 'edit' ? props.group?.id : undefined,
|
||||||
|
name: form.name.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success(props.mode === 'create' ? '权限组已创建' : '权限组已更新')
|
||||||
|
emit('success')
|
||||||
|
closeDialog()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '权限组保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
form.name = props.group?.name ?? ''
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
:model-value="props.visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="min(480px, calc(100vw - 32px))"
|
||||||
|
class="node-group-dialog"
|
||||||
|
destroy-on-close
|
||||||
|
@close="closeDialog"
|
||||||
|
@update:model-value="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<div class="dialog-shell">
|
||||||
|
<p class="dialog-description">{{ dialogDescription }}</p>
|
||||||
|
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<ElFormItem label="组名称" prop="name">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.name"
|
||||||
|
maxlength="30"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入有意义的权限组名称"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ props.mode === 'create' ? '创建' : '更新' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-description {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
.node-groups-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(34px, 4.8vw, 52px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary,
|
||||||
|
.table-toolbar,
|
||||||
|
.toolbar-left,
|
||||||
|
.table-footer,
|
||||||
|
.action-group,
|
||||||
|
.metric-chip,
|
||||||
|
.metric-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 113, 227, 0.06);
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--xboard-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar,
|
||||||
|
.table-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
width: min(320px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-groups-table :deep(th.el-table__cell) {
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
background: #fbfbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-groups-table :deep(.el-table__row td.el-table__cell) {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell strong {
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell span,
|
||||||
|
.table-footer span,
|
||||||
|
.metric-chip.is-muted {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-chip,
|
||||||
|
.metric-link {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-chip {
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn {
|
||||||
|
color: var(--xboard-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-empty {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.table-toolbar,
|
||||||
|
.table-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +1,256 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const milestones = [
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
'接入权限组列表与用户 / 节点引用统计',
|
import { useRouter } from 'vue-router'
|
||||||
'补齐新增、编辑、删除与使用冲突提示',
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
'联动节点页的权限组筛选与维护闭环',
|
import {
|
||||||
]
|
Connection,
|
||||||
|
Delete,
|
||||||
|
EditPen,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
deleteServerGroup,
|
||||||
|
getServerGroups,
|
||||||
|
} from '@/api/admin'
|
||||||
|
import type { AdminServerGroupItem } from '@/types/api'
|
||||||
|
import {
|
||||||
|
filterNodeGroups,
|
||||||
|
normalizeNodeGroup,
|
||||||
|
summarizeNodeGroups,
|
||||||
|
} from '@/utils/nodeGroups'
|
||||||
|
import NodeGroupEditorDialog from './NodeGroupEditorDialog.vue'
|
||||||
|
|
||||||
|
type DialogMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const current = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const groups = ref<AdminServerGroupItem[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<DialogMode>('create')
|
||||||
|
const activeGroup = ref<AdminServerGroupItem | null>(null)
|
||||||
|
|
||||||
|
const filteredGroups = computed(() => filterNodeGroups(groups.value, keyword.value))
|
||||||
|
const visibleGroups = computed(() => {
|
||||||
|
const start = (current.value - 1) * pageSize.value
|
||||||
|
return filteredGroups.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
const summary = computed(() => summarizeNodeGroups(groups.value))
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getServerGroups()
|
||||||
|
groups.value = (response.data ?? []).map((item) => normalizeNodeGroup(item))
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '权限组数据加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
activeGroup.value = null
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(group: AdminServerGroupItem) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
activeGroup.value = group
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogSuccess() {
|
||||||
|
void loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(group: AdminServerGroupItem) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`删除权限组「${group.name}」后无法恢复,确认继续吗?`,
|
||||||
|
'删除权限组',
|
||||||
|
{ type: 'warning' },
|
||||||
|
)
|
||||||
|
await deleteServerGroup(group.id)
|
||||||
|
ElMessage.success('权限组已删除')
|
||||||
|
await loadPage()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '权限组删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNodeFilter(group: AdminServerGroupItem) {
|
||||||
|
void router.push({
|
||||||
|
path: '/nodes',
|
||||||
|
query: { group: String(group.id) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(keyword, () => {
|
||||||
|
current.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filteredGroups, (list) => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||||
|
if (current.value > maxPage) {
|
||||||
|
current.value = maxPage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(pageSize, () => {
|
||||||
|
current.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadPage()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="placeholder-page">
|
<div class="node-groups-page">
|
||||||
<section class="placeholder-hero">
|
<section class="page-header">
|
||||||
<div class="placeholder-copy">
|
<div class="page-copy">
|
||||||
<p class="placeholder-kicker">Node Groups</p>
|
|
||||||
<h1>权限组管理</h1>
|
<h1>权限组管理</h1>
|
||||||
<span>入口已预留。本轮先完成节点列表主链路,下一阶段继续接入权限组的真实维护能力。</span>
|
<p>管理所有权限组,包括添加、删除、编辑等操作。</p>
|
||||||
|
<div class="page-summary">
|
||||||
|
<span>共 {{ groups.length }} 组</span>
|
||||||
|
<span>关联用户 {{ summary.totalUsers }}</span>
|
||||||
|
<span>关联节点 {{ summary.totalServers }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="placeholder-card">
|
<section class="table-shell">
|
||||||
<header>
|
<ElAlert
|
||||||
<h2>下一阶段计划</h2>
|
v-if="errorMessage"
|
||||||
<p>这一页不会空着结束,而是明确告诉你后续要接什么。</p>
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
:title="errorMessage"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElButton size="small" @click="loadPage">重新加载</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElAlert>
|
||||||
|
|
||||||
|
<header class="table-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<ElButton type="primary" @click="openCreateDialog">
|
||||||
|
<ElIcon><Plus /></ElIcon>
|
||||||
|
添加权限组
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ElInput
|
||||||
|
v-model="keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索权限组..."
|
||||||
|
class="toolbar-search"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ElIcon><Search /></ElIcon>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ol>
|
<ElTable
|
||||||
<li v-for="item in milestones" :key="item">{{ item }}</li>
|
:data="visibleGroups"
|
||||||
</ol>
|
v-loading="loading"
|
||||||
|
row-key="id"
|
||||||
|
class="node-groups-table"
|
||||||
|
empty-text="当前筛选条件下暂无权限组"
|
||||||
|
>
|
||||||
|
<ElTableColumn prop="id" label="组ID" width="104" />
|
||||||
|
<ElTableColumn label="组名称" min-width="280">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="name-cell">
|
||||||
|
<strong>{{ row.name }}</strong>
|
||||||
|
<span>用于节点、套餐与用户的权限范围归属</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="用户数量" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="metric-chip">
|
||||||
|
<ElIcon><User /></ElIcon>
|
||||||
|
{{ row.users_count ?? 0 }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="节点数量" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton
|
||||||
|
v-if="Number(row.server_count ?? 0) > 0"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="metric-link"
|
||||||
|
@click="openNodeFilter(row)"
|
||||||
|
>
|
||||||
|
<ElIcon><Connection /></ElIcon>
|
||||||
|
{{ row.server_count }}
|
||||||
|
</ElButton>
|
||||||
|
<span v-else class="metric-chip is-muted">
|
||||||
|
<ElIcon><Connection /></ElIcon>
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-group">
|
||||||
|
<ElButton text class="action-btn" @click="openEditDialog(row)">
|
||||||
|
<ElIcon><EditPen /></ElIcon>
|
||||||
|
</ElButton>
|
||||||
|
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
|
||||||
|
<ElIcon><Delete /></ElIcon>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="table-empty">
|
||||||
|
<ElEmpty :description="keyword ? '当前搜索条件下暂无权限组。' : '暂无权限组数据。'">
|
||||||
|
<ElButton v-if="keyword" @click="keyword = ''">清空搜索</ElButton>
|
||||||
|
<ElButton v-else @click="loadPage">重新加载</ElButton>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<footer class="table-footer">
|
||||||
|
<span>已选择 0 项,共 {{ filteredGroups.length }} 项</span>
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="current"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="sizes, prev, pager, next"
|
||||||
|
:total="filteredGroups.length"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<NodeGroupEditorDialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:mode="dialogMode"
|
||||||
|
:group="activeGroup"
|
||||||
|
@success="handleDialogSuccess"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss" src="./NodeGroupsView.scss"></style>
|
||||||
.placeholder-page {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-hero {
|
|
||||||
padding: 30px 32px;
|
|
||||||
border-radius: 28px;
|
|
||||||
background: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-kicker {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.24em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.68);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-copy h1 {
|
|
||||||
font-size: clamp(34px, 5vw, 48px);
|
|
||||||
line-height: 1.08;
|
|
||||||
letter-spacing: -0.28px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-copy span {
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
line-height: 1.47;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
padding: 28px;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: var(--xboard-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card header {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card h2 {
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: var(--xboard-text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card p,
|
|
||||||
.placeholder-card li {
|
|
||||||
color: var(--xboard-text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card ol {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.dialog-shell,
|
||||||
|
.dialog-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-copy p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-copy h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.08;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-copy span,
|
||||||
|
.field-help span {
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
line-height: 1.47;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fbfbfd;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-panel__main,
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-panel strong {
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-panel span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dialog-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { saveNodeRoute } from '@/api/admin'
|
||||||
|
import type { AdminNodeRouteItem } from '@/types/api'
|
||||||
|
import {
|
||||||
|
createEmptyNodeRouteForm,
|
||||||
|
getNodeRouteActionMeta,
|
||||||
|
getNodeRouteActionValueLabel,
|
||||||
|
getNodeRouteActionValuePlaceholder,
|
||||||
|
NODE_ROUTE_ACTION_OPTIONS,
|
||||||
|
parseRouteMatchLines,
|
||||||
|
requiresNodeRouteActionValue,
|
||||||
|
toNodeRouteFormModel,
|
||||||
|
toNodeRouteSavePayload,
|
||||||
|
type NodeRouteFormModel,
|
||||||
|
} from '@/utils/routes'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
route?: AdminNodeRouteItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
success: [message: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const form = reactive<NodeRouteFormModel>(createEmptyNodeRouteForm())
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => props.mode === 'create' ? '添加路由' : '编辑路由')
|
||||||
|
const needsActionValue = computed(() => requiresNodeRouteActionValue(form.action))
|
||||||
|
const actionMeta = computed(() => getNodeRouteActionMeta(form.action))
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncForm() {
|
||||||
|
Object.assign(form, toNodeRouteFormModel(props.route))
|
||||||
|
if (!needsActionValue.value) {
|
||||||
|
form.actionValue = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMatchText(_rule: unknown, value: string, callback: (error?: Error) => void) {
|
||||||
|
if (parseRouteMatchLines(value).length === 0) {
|
||||||
|
callback(new Error('请至少输入一条匹配规则'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateActionValue(_rule: unknown, value: string, callback: (error?: Error) => void) {
|
||||||
|
if (needsActionValue.value && !value.trim()) {
|
||||||
|
callback(new Error(`请输入${getNodeRouteActionValueLabel(form.action)}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = computed<FormRules<NodeRouteFormModel>>(() => ({
|
||||||
|
remarks: [{ required: true, message: '请输入备注', trigger: 'blur' }],
|
||||||
|
matchText: [{ validator: validateMatchText, trigger: 'blur' }],
|
||||||
|
action: [{ required: true, message: '请选择动作', trigger: 'change' }],
|
||||||
|
actionValue: [{ validator: validateActionValue, trigger: 'blur' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const instance = formRef.value
|
||||||
|
if (!instance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await instance.validate().catch(() => false)
|
||||||
|
if (!valid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await saveNodeRoute(toNodeRouteSavePayload(form))
|
||||||
|
const message = props.mode === 'create' ? '路由已创建' : '路由已更新'
|
||||||
|
ElMessage.success(message)
|
||||||
|
emit('success', message)
|
||||||
|
closeDialog()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '路由保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.route, props.mode],
|
||||||
|
([visible]) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncForm()
|
||||||
|
nextTick(() => {
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.action,
|
||||||
|
() => {
|
||||||
|
if (!needsActionValue.value) {
|
||||||
|
form.actionValue = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
:model-value="props.visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="min(640px, calc(100vw - 32px))"
|
||||||
|
destroy-on-close
|
||||||
|
class="node-route-editor-dialog"
|
||||||
|
@close="closeDialog"
|
||||||
|
@update:model-value="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<div class="dialog-shell">
|
||||||
|
<div class="dialog-copy">
|
||||||
|
<p>Node Routes</p>
|
||||||
|
<h2>{{ dialogTitle }}</h2>
|
||||||
|
<span>维护路由备注、匹配规则与动作配置;保存后会同步到节点侧使用的路由规则。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
class="dialog-form"
|
||||||
|
>
|
||||||
|
<ElFormItem label="备注" prop="remarks">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.remarks"
|
||||||
|
placeholder="例如:屏蔽广告、走指定 DNS"
|
||||||
|
maxlength="80"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="匹配规则" prop="matchText">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.matchText"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 5, maxRows: 8 }"
|
||||||
|
placeholder="每行一条规则,例如: test.com *.apple.com"
|
||||||
|
/>
|
||||||
|
<div class="field-help">
|
||||||
|
<span>每行一条规则,保存时会自动去空与去重。</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<div class="dialog-grid">
|
||||||
|
<ElFormItem label="动作" prop="action">
|
||||||
|
<ElSelect v-model="form.action" placeholder="请选择动作">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in NODE_ROUTE_ACTION_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem
|
||||||
|
v-if="needsActionValue"
|
||||||
|
:label="getNodeRouteActionValueLabel(form.action)"
|
||||||
|
prop="actionValue"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model="form.actionValue"
|
||||||
|
:placeholder="getNodeRouteActionValuePlaceholder(form.action)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="action-panel">
|
||||||
|
<div class="action-panel__main">
|
||||||
|
<strong>当前动作</strong>
|
||||||
|
<ElTag round effect="plain" :type="actionMeta.tagType">
|
||||||
|
{{ actionMeta.label }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<span v-if="needsActionValue">
|
||||||
|
{{ getNodeRouteActionValueLabel(form.action) }} 会随当前路由一起下发到节点端。
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
当前动作不需要额外动作值,保存后会直接按策略执行。
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ props.mode === 'create' ? '提交' : '保存修改' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss" src="./NodeRouteEditorDialog.scss"></style>
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
.node-routes-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-routes-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 34px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: clamp(34px, 5vw, 52px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy span {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 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: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px 22px 18px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--xboard-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar,
|
||||||
|
.toolbar-left,
|
||||||
|
.table-footer,
|
||||||
|
.footer-right,
|
||||||
|
.footer-hint,
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar,
|
||||||
|
.table-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-alert {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routes-table :deep(.el-table__cell) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routes-table :deep(th.el-table__cell) {
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
background: #fbfbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-cell,
|
||||||
|
.value-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-cell strong,
|
||||||
|
.value-cell strong {
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-cell span,
|
||||||
|
.value-cell span,
|
||||||
|
.table-footer > span,
|
||||||
|
.footer-hint span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-tag,
|
||||||
|
.action-tag {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-tag--dns {
|
||||||
|
color: #0071e3;
|
||||||
|
border-color: rgba(0, 113, 227, 0.18);
|
||||||
|
background: rgba(0, 113, 227, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
color: #0071e3;
|
||||||
|
background: rgba(0, 113, 227, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn:hover {
|
||||||
|
color: #d92d20;
|
||||||
|
background: rgba(217, 45, 32, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-empty {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-hint {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.node-routes-hero,
|
||||||
|
.table-toolbar,
|
||||||
|
.table-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.hero-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.footer-right {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-hint {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +1,299 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const milestones = [
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
'接入路由规则列表、动作类型与备注字段',
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
'补齐新增 / 编辑 / 删除路由的操作台',
|
import {
|
||||||
'与节点页建立路由引用可视化关系,方便运营判断影响面',
|
Connection,
|
||||||
]
|
Delete,
|
||||||
|
EditPen,
|
||||||
|
Plus,
|
||||||
|
RefreshRight,
|
||||||
|
Search,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
deleteNodeRoute,
|
||||||
|
fetchNodeRoutes,
|
||||||
|
fetchNodes,
|
||||||
|
} from '@/api/admin'
|
||||||
|
import type { AdminNodeItem, AdminNodeRouteItem } from '@/types/api'
|
||||||
|
import NodeRouteEditorDialog from './NodeRouteEditorDialog.vue'
|
||||||
|
import {
|
||||||
|
buildNodeRouteReferenceMap,
|
||||||
|
countReferencedNodeRoutes,
|
||||||
|
filterNodeRoutes,
|
||||||
|
formatNodeRouteActionValue,
|
||||||
|
getNodeRouteActionMeta,
|
||||||
|
normalizeNodeRoute,
|
||||||
|
} from '@/utils/routes'
|
||||||
|
|
||||||
|
type DialogMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editorMode = ref<DialogMode>('create')
|
||||||
|
const activeRoute = ref<AdminNodeRouteItem | null>(null)
|
||||||
|
const deletingId = ref<number | null>(null)
|
||||||
|
const keyword = ref('')
|
||||||
|
const current = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const routes = ref<AdminNodeRouteItem[]>([])
|
||||||
|
const nodes = ref<AdminNodeItem[]>([])
|
||||||
|
|
||||||
|
const referenceMap = computed(() => buildNodeRouteReferenceMap(nodes.value))
|
||||||
|
const filteredRoutes = computed(() => filterNodeRoutes(routes.value, keyword.value, referenceMap.value))
|
||||||
|
const visibleRoutes = computed(() => {
|
||||||
|
const start = (current.value - 1) * pageSize.value
|
||||||
|
return filteredRoutes.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroStats = computed(() => [
|
||||||
|
{ label: '路由总数', value: String(routes.value.length) },
|
||||||
|
{ label: '禁止访问', value: String(routes.value.filter((item) => item.action === 'block').length) },
|
||||||
|
{ label: 'DNS 解析', value: String(routes.value.filter((item) => item.action === 'dns').length) },
|
||||||
|
{ label: '已被引用', value: String(countReferencedNodeRoutes(routes.value, referenceMap.value)) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() => keyword.value.trim() !== '')
|
||||||
|
|
||||||
|
function isDeleting(id: number): boolean {
|
||||||
|
return deletingId.value === id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [routeResult, nodeResult] = await Promise.all([
|
||||||
|
fetchNodeRoutes(),
|
||||||
|
fetchNodes(),
|
||||||
|
])
|
||||||
|
|
||||||
|
routes.value = (routeResult.data ?? [])
|
||||||
|
.map((route) => normalizeNodeRoute(route))
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
nodes.value = nodeResult.data ?? []
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '路由数据加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editorMode.value = 'create'
|
||||||
|
activeRoute.value = null
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(route: AdminNodeRouteItem) {
|
||||||
|
editorMode.value = 'edit'
|
||||||
|
activeRoute.value = route
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
keyword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(route: AdminNodeRouteItem) {
|
||||||
|
deletingId.value = route.id
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除路由「${route.remarks}」后无法恢复,确认继续吗?`, '删除路由', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteNodeRoute(route.id)
|
||||||
|
ElMessage.success('路由已删除')
|
||||||
|
await loadData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '路由删除失败')
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([keyword, pageSize], () => {
|
||||||
|
current.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filteredRoutes, (list) => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||||
|
if (current.value > maxPage) {
|
||||||
|
current.value = maxPage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadData().catch((error) => {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '路由管理页面初始化失败')
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="placeholder-page">
|
<div class="node-routes-page">
|
||||||
<section class="placeholder-hero">
|
<section class="node-routes-hero">
|
||||||
<div class="placeholder-copy">
|
<div class="hero-copy">
|
||||||
<p class="placeholder-kicker">Node Routes</p>
|
<p class="hero-kicker">Node Routes</p>
|
||||||
<h1>路由管理</h1>
|
<h1>路由管理</h1>
|
||||||
<span>侧边栏入口已对齐,下一阶段将继续补齐路由规则列表与节点引用关系。</span>
|
<span>管理所有路由规则,包括添加、删除、编辑与节点引用摘要查看。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-stats">
|
||||||
|
<article v-for="item in heroStats" :key="item.label">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="placeholder-card">
|
<section class="table-shell">
|
||||||
<header>
|
<header class="table-toolbar">
|
||||||
<h2>接下来会补什么</h2>
|
<div class="toolbar-left">
|
||||||
<p>本轮先把节点管理主链路落稳,路由管理不留空白,先把后续接入方向固定下来。</p>
|
<ElButton type="primary" @click="openCreateDialog">
|
||||||
|
<ElIcon><Plus /></ElIcon>
|
||||||
|
添加路由
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ElInput
|
||||||
|
v-model="keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索路由..."
|
||||||
|
class="toolbar-search"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ElIcon><Search /></ElIcon>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ol>
|
<ElAlert
|
||||||
<li v-for="item in milestones" :key="item">{{ item }}</li>
|
v-if="errorMessage"
|
||||||
</ol>
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="table-alert"
|
||||||
|
:title="errorMessage"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElButton text @click="loadData">重新加载</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElAlert>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
:data="visibleRoutes"
|
||||||
|
v-loading="loading"
|
||||||
|
class="routes-table"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="当前筛选条件下暂无路由"
|
||||||
|
>
|
||||||
|
<ElTableColumn label="组ID" width="108">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag round effect="plain" class="id-tag">
|
||||||
|
{{ row.id }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="备注" min-width="320">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="remark-cell">
|
||||||
|
<strong>{{ row.remarks }}</strong>
|
||||||
|
<span v-if="referenceMap[row.id]?.count">
|
||||||
|
引用 {{ referenceMap[row.id].count }} 个节点 · {{ referenceMap[row.id].preview }}
|
||||||
|
</span>
|
||||||
|
<span v-else>未被节点引用</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="动作值" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="value-cell">
|
||||||
|
<strong>{{ formatNodeRouteActionValue(row) }}</strong>
|
||||||
|
<span>匹配 {{ row.match.length }} 条规则</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="动作" width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag
|
||||||
|
round
|
||||||
|
effect="plain"
|
||||||
|
:type="getNodeRouteActionMeta(row.action).tagType"
|
||||||
|
class="action-tag"
|
||||||
|
:class="`action-tag--${row.action}`"
|
||||||
|
>
|
||||||
|
{{ getNodeRouteActionMeta(row.action).label }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="操作" width="110" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-group">
|
||||||
|
<ElButton text class="action-btn" @click="openEditDialog(row)">
|
||||||
|
<ElIcon><EditPen /></ElIcon>
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
class="action-btn danger-btn"
|
||||||
|
:loading="isDeleting(row.id)"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
<ElIcon><Delete /></ElIcon>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="table-empty">
|
||||||
|
<ElEmpty
|
||||||
|
:description="hasActiveFilters ? '当前筛选条件下暂无路由。' : '暂无路由数据。'"
|
||||||
|
>
|
||||||
|
<ElButton v-if="hasActiveFilters" @click="handleReset">清空筛选</ElButton>
|
||||||
|
<ElButton v-else @click="loadData">
|
||||||
|
<ElIcon><RefreshRight /></ElIcon>
|
||||||
|
重新加载
|
||||||
|
</ElButton>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<footer class="table-footer">
|
||||||
|
<span>已选择 0 项,共 {{ filteredRoutes.length }} 项</span>
|
||||||
|
<div class="footer-right">
|
||||||
|
<div class="footer-hint">
|
||||||
|
<ElIcon><Connection /></ElIcon>
|
||||||
|
<span>节点引用摘要基于当前节点 `route_ids` 实时推导。</span>
|
||||||
|
</div>
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="current"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="sizes, prev, pager, next"
|
||||||
|
:total="filteredRoutes.length"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<NodeRouteEditorDialog
|
||||||
|
v-model:visible="editorVisible"
|
||||||
|
:mode="editorMode"
|
||||||
|
:route="activeRoute"
|
||||||
|
@success="() => loadData()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss" src="./NodeRoutesView.scss"></style>
|
||||||
.placeholder-page {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-hero {
|
|
||||||
padding: 30px 32px;
|
|
||||||
border-radius: 28px;
|
|
||||||
background: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-kicker {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.24em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.68);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-copy h1 {
|
|
||||||
font-size: clamp(34px, 5vw, 48px);
|
|
||||||
line-height: 1.08;
|
|
||||||
letter-spacing: -0.28px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-copy span {
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
line-height: 1.47;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
padding: 28px;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: var(--xboard-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card header {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card h2 {
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: var(--xboard-text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card p,
|
|
||||||
.placeholder-card li {
|
|
||||||
color: var(--xboard-text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-card ol {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.sort-shell,
|
||||||
|
.sort-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-copy,
|
||||||
|
.sort-meta span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-item,
|
||||||
|
.sort-item__main,
|
||||||
|
.sort-actions,
|
||||||
|
.sort-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-item,
|
||||||
|
.sort-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fbfbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-index {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 113, 227, 0.08);
|
||||||
|
color: #0071e3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-meta strong {
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sort-item,
|
||||||
|
.sort-item__main,
|
||||||
|
.sort-actions,
|
||||||
|
.sort-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-index {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||||
|
import { sortNodes } from '@/api/admin'
|
||||||
|
import type { AdminNodeItem } from '@/types/api'
|
||||||
|
import { getNodeProtocolLabel, moveNodeOrder, sortNodesByOrder } from '@/utils/nodeEditor'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
nodes: AdminNodeItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
success: [message: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const draft = ref<AdminNodeItem[]>([])
|
||||||
|
|
||||||
|
const sortedDraft = computed(() => draft.value)
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDraft(index: number, direction: -1 | 1) {
|
||||||
|
draft.value = moveNodeOrder(draft.value, index, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await sortNodes(
|
||||||
|
draft.value.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
order: index + 1,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const message = '节点排序已保存'
|
||||||
|
ElMessage.success(message)
|
||||||
|
emit('success', message)
|
||||||
|
closeDialog()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '节点排序保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.nodes],
|
||||||
|
([visible]) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.value = sortNodesByOrder(props.nodes).map((item) => ({ ...item }))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
:model-value="props.visible"
|
||||||
|
width="min(720px, calc(100vw - 32px))"
|
||||||
|
class="node-sort-dialog"
|
||||||
|
title="编辑排序"
|
||||||
|
@close="closeDialog"
|
||||||
|
@update:model-value="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<div class="sort-shell">
|
||||||
|
<p class="sort-copy">按照当前展示顺序调整节点排序,保存后会同步到后台 `/server/manage/sort`。</p>
|
||||||
|
|
||||||
|
<div class="sort-list">
|
||||||
|
<article
|
||||||
|
v-for="(item, index) in sortedDraft"
|
||||||
|
:key="item.id"
|
||||||
|
class="sort-item"
|
||||||
|
>
|
||||||
|
<div class="sort-item__main">
|
||||||
|
<span class="sort-index">{{ index + 1 }}</span>
|
||||||
|
<div class="sort-meta">
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
<span>
|
||||||
|
{{ getNodeProtocolLabel(item.type) }}
|
||||||
|
· {{ item.host }}:{{ item.server_port || item.port }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sort-actions">
|
||||||
|
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
|
||||||
|
<ElIcon><ArrowUp /></ElIcon>
|
||||||
|
上移
|
||||||
|
</ElButton>
|
||||||
|
<ElButton :disabled="index === sortedDraft.length - 1" @click="moveDraft(index, 1)">
|
||||||
|
<ElIcon><ArrowDown /></ElIcon>
|
||||||
|
下移
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="sort-footer">
|
||||||
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
保存排序
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss" src="./NodeSortDialog.scss"></style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Connection,
|
Connection,
|
||||||
@@ -13,10 +14,13 @@ import {
|
|||||||
copyNode,
|
copyNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
fetchNodes,
|
fetchNodes,
|
||||||
|
fetchNodeRoutes,
|
||||||
getServerGroups,
|
getServerGroups,
|
||||||
updateNode,
|
updateNode,
|
||||||
} from '@/api/admin'
|
} from '@/api/admin'
|
||||||
import type { AdminNodeItem, AdminServerGroupItem } from '@/types/api'
|
import type { AdminNodeItem, AdminNodeRouteItem, AdminServerGroupItem } from '@/types/api'
|
||||||
|
import NodeEditorDialog from './NodeEditorDialog.vue'
|
||||||
|
import NodeSortDialog from './NodeSortDialog.vue'
|
||||||
import {
|
import {
|
||||||
buildNodeTypeOptions,
|
buildNodeTypeOptions,
|
||||||
countOnlineNodes,
|
countOnlineNodes,
|
||||||
@@ -29,25 +33,34 @@ import {
|
|||||||
getNodeStatusMeta,
|
getNodeStatusMeta,
|
||||||
getNodeTypeLabel,
|
getNodeTypeLabel,
|
||||||
} from '@/utils/nodes'
|
} from '@/utils/nodes'
|
||||||
|
import { sortNodesByOrder } from '@/utils/nodeEditor'
|
||||||
|
|
||||||
type NodeAction = 'edit' | 'copy' | 'delete'
|
type NodeAction = 'edit' | 'copy' | 'delete'
|
||||||
|
type NodeDialogMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const nodes = ref<AdminNodeItem[]>([])
|
const nodes = ref<AdminNodeItem[]>([])
|
||||||
const groups = ref<AdminServerGroupItem[]>([])
|
const groups = ref<AdminServerGroupItem[]>([])
|
||||||
|
const routes = ref<AdminNodeRouteItem[]>([])
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const typeFilter = ref('all')
|
const typeFilter = ref('all')
|
||||||
const groupFilter = ref('all')
|
const groupFilter = ref('all')
|
||||||
const switchingIds = ref<number[]>([])
|
const switchingIds = ref<number[]>([])
|
||||||
const workingIds = ref<number[]>([])
|
const workingIds = ref<number[]>([])
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editorMode = ref<NodeDialogMode>('create')
|
||||||
|
const activeNode = ref<AdminNodeItem | null>(null)
|
||||||
|
const sortDialogVisible = ref(false)
|
||||||
|
|
||||||
const filteredNodes = computed(() => filterNodes(
|
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
|
||||||
nodes.value,
|
nodes.value,
|
||||||
keyword.value,
|
keyword.value,
|
||||||
typeFilter.value,
|
typeFilter.value,
|
||||||
groupFilter.value,
|
groupFilter.value,
|
||||||
))
|
)))
|
||||||
|
|
||||||
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
|
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
|
||||||
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.value !== 'all')
|
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.value !== 'all')
|
||||||
@@ -59,6 +72,24 @@ const summaryCards = computed(() => [
|
|||||||
{ label: '当前结果', value: String(filteredNodes.value.length) },
|
{ label: '当前结果', value: String(filteredNodes.value.length) },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function getRouteGroupQuery(): string {
|
||||||
|
const rawValue = route.query.group
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
return String(rawValue[0] ?? '')
|
||||||
|
}
|
||||||
|
return String(rawValue ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRouteGroupFilter() {
|
||||||
|
const groupValue = getRouteGroupQuery().trim()
|
||||||
|
if (!groupValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = groups.value.some((group) => String(group.id) === groupValue)
|
||||||
|
groupFilter.value = exists ? groupValue : 'all'
|
||||||
|
}
|
||||||
|
|
||||||
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
|
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
|
||||||
if (pending) {
|
if (pending) {
|
||||||
if (!list.value.includes(id)) {
|
if (!list.value.includes(id)) {
|
||||||
@@ -78,8 +109,20 @@ function isWorking(id: number): boolean {
|
|||||||
return workingIds.value.includes(id)
|
return workingIds.value.includes(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyPending(scope: string) {
|
function openCreateEditor() {
|
||||||
ElMessage.info(`${scope} 会在下一阶段接入,本轮已先打通节点列表主链路。`)
|
editorMode.value = 'create'
|
||||||
|
activeNode.value = null
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditEditor(node: AdminNodeItem) {
|
||||||
|
editorMode.value = 'edit'
|
||||||
|
activeNode.value = node
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSortEditor() {
|
||||||
|
sortDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNodeBoard() {
|
async function loadNodeBoard() {
|
||||||
@@ -87,13 +130,16 @@ async function loadNodeBoard() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [nodesResponse, groupsResponse] = await Promise.all([
|
const [nodesResponse, groupsResponse, routesResponse] = await Promise.all([
|
||||||
fetchNodes(),
|
fetchNodes(),
|
||||||
getServerGroups(),
|
getServerGroups(),
|
||||||
|
fetchNodeRoutes(),
|
||||||
])
|
])
|
||||||
|
|
||||||
nodes.value = nodesResponse.data ?? []
|
nodes.value = sortNodesByOrder(nodesResponse.data ?? [])
|
||||||
groups.value = groupsResponse.data ?? []
|
groups.value = groupsResponse.data ?? []
|
||||||
|
routes.value = routesResponse.data ?? []
|
||||||
|
applyRouteGroupFilter()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
|
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,6 +153,10 @@ function handleReset() {
|
|||||||
groupFilter.value = 'all'
|
groupFilter.value = 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNodeGroupManagement() {
|
||||||
|
void router.push('/node-groups')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
|
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
|
||||||
const previous = Boolean(node.show)
|
const previous = Boolean(node.show)
|
||||||
if (previous === nextValue) {
|
if (previous === nextValue) {
|
||||||
@@ -132,7 +182,7 @@ async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
|
|||||||
|
|
||||||
async function handleAction(action: NodeAction, node: AdminNodeItem) {
|
async function handleAction(action: NodeAction, node: AdminNodeItem) {
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
notifyPending(`编辑节点 #${node.id}`)
|
openEditEditor(node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +219,13 @@ async function handleAction(action: NodeAction, node: AdminNodeItem) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadNodeBoard()
|
void loadNodeBoard()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.group,
|
||||||
|
() => {
|
||||||
|
applyRouteGroupFilter()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -193,7 +250,7 @@ onMounted(() => {
|
|||||||
<section class="nodes-board">
|
<section class="nodes-board">
|
||||||
<header class="board-toolbar">
|
<header class="board-toolbar">
|
||||||
<div class="toolbar-fields">
|
<div class="toolbar-fields">
|
||||||
<ElButton type="primary" @click="notifyPending('添加节点')">
|
<ElButton type="primary" @click="openCreateEditor">
|
||||||
<ElIcon><Plus /></ElIcon>
|
<ElIcon><Plus /></ElIcon>
|
||||||
添加节点
|
添加节点
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -231,11 +288,12 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
|
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
|
||||||
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
|
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
|
||||||
<ElIcon><RefreshRight /></ElIcon>
|
<ElIcon><RefreshRight /></ElIcon>
|
||||||
重置筛选
|
重置筛选
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton @click="notifyPending('编辑排序')">编辑排序</ElButton>
|
<ElButton @click="openSortEditor">编辑排序</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -362,8 +420,8 @@ onMounted(() => {
|
|||||||
<ElIcon><MoreFilled /></ElIcon>
|
<ElIcon><MoreFilled /></ElIcon>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
<ElDropdownItem command="edit">编辑节点(下一阶段)</ElDropdownItem>
|
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
|
||||||
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
|
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
|
||||||
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
|
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
|
||||||
</ElDropdownMenu>
|
</ElDropdownMenu>
|
||||||
@@ -391,10 +449,26 @@ onMounted(() => {
|
|||||||
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
|
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
|
||||||
<div class="footer-hint">
|
<div class="footer-hint">
|
||||||
<ElIcon><Connection /></ElIcon>
|
<ElIcon><Connection /></ElIcon>
|
||||||
<span>完整的节点创建、编辑与排序流程将在下一阶段补齐。</span>
|
<span>节点新增、编辑与排序已在当前工作台内接入真实流程。</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<NodeEditorDialog
|
||||||
|
v-model:visible="editorVisible"
|
||||||
|
:mode="editorMode"
|
||||||
|
:node="activeNode"
|
||||||
|
:groups="groups"
|
||||||
|
:routes="routes"
|
||||||
|
:nodes="nodes"
|
||||||
|
@success="() => loadNodeBoard()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NodeSortDialog
|
||||||
|
v-model:visible="sortDialogVisible"
|
||||||
|
:nodes="nodes"
|
||||||
|
@success="() => loadNodeBoard()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { generateGiftCardCodes } from '@/api/admin'
|
||||||
|
import type { AdminGiftCardTemplateItem } from '@/types/api'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
templates: AdminGiftCardTemplateItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', value: boolean): void
|
||||||
|
(e: 'success', payload: { batchId: string }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const saving = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
template_id: undefined as number | undefined,
|
||||||
|
count: 10,
|
||||||
|
prefix: 'GC',
|
||||||
|
expires_hours: undefined as number | undefined,
|
||||||
|
max_usage: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules<typeof form> = {
|
||||||
|
template_id: [{ required: true, message: '请选择模板', trigger: 'change' }],
|
||||||
|
count: [{ required: true, message: '请输入生成数量', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.template_id = undefined
|
||||||
|
form.count = 10
|
||||||
|
form.prefix = 'GC'
|
||||||
|
form.expires_hours = undefined
|
||||||
|
form.max_usage = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || !form.template_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const response = await generateGiftCardCodes({
|
||||||
|
template_id: form.template_id,
|
||||||
|
count: form.count,
|
||||||
|
prefix: form.prefix.trim() || 'GC',
|
||||||
|
expires_hours: form.expires_hours,
|
||||||
|
max_usage: form.max_usage,
|
||||||
|
})
|
||||||
|
ElMessage.success(`兑换码已生成,本次批次:${response.data.batch_id}`)
|
||||||
|
emit('success', { batchId: response.data.batch_id })
|
||||||
|
closeDialog()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '兑换码生成失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
:model-value="visible"
|
||||||
|
title="生成兑换码"
|
||||||
|
width="520px"
|
||||||
|
@update:model-value="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top" class="batch-form">
|
||||||
|
<ElFormItem label="模板" prop="template_id">
|
||||||
|
<ElSelect v-model="form.template_id" placeholder="请选择一个礼品卡模板">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in templates"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<ElFormItem label="生成数量" prop="count">
|
||||||
|
<ElInputNumber v-model="form.count" :min="1" :max="10000" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="最大使用次数">
|
||||||
|
<ElInputNumber v-model="form.max_usage" :min="1" :max="1000" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="前缀">
|
||||||
|
<ElInput v-model="form.prefix" maxlength="10" placeholder="例如 GC" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="有效期(小时)">
|
||||||
|
<ElInputNumber v-model="form.expires_hours" :min="1" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="生成后可在兑换码管理中按批次导出文本文件,并继续做启停、编辑或删除操作。"
|
||||||
|
/>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="saving" @click="handleSubmit">开始生成</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.batch-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
CopyDocument,
|
||||||
|
Delete,
|
||||||
|
Download,
|
||||||
|
EditPen,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
AdminGiftCardCodeItem,
|
||||||
|
AdminGiftCardTemplateItem,
|
||||||
|
} from '@/types/api'
|
||||||
|
import type { GiftCardCodeStatusFilter } from '@/utils/giftCards'
|
||||||
|
import {
|
||||||
|
formatGiftCardDateTime,
|
||||||
|
getGiftCardAvailableUsage,
|
||||||
|
getGiftCardCodeStatusMeta,
|
||||||
|
} from '@/utils/giftCards'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
codes: AdminGiftCardCodeItem[]
|
||||||
|
keyword: string
|
||||||
|
templateFilter: number | 'all'
|
||||||
|
statusFilter: GiftCardCodeStatusFilter
|
||||||
|
current: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
templates: AdminGiftCardTemplateItem[]
|
||||||
|
resolvedBatchId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:keyword', value: string): void
|
||||||
|
(e: 'update:template-filter', value: number | 'all'): void
|
||||||
|
(e: 'update:status-filter', value: GiftCardCodeStatusFilter): void
|
||||||
|
(e: 'update:current', value: number): void
|
||||||
|
(e: 'update:page-size', value: number): void
|
||||||
|
(e: 'create'): void
|
||||||
|
(e: 'export'): void
|
||||||
|
(e: 'reset'): void
|
||||||
|
(e: 'copy', code: string): void
|
||||||
|
(e: 'select-batch', batchId: string): void
|
||||||
|
(e: 'edit', code: AdminGiftCardCodeItem): void
|
||||||
|
(e: 'delete', code: AdminGiftCardCodeItem): void
|
||||||
|
(e: 'toggle', code: AdminGiftCardCodeItem, nextValue: string | number | boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const keywordModel = computed({
|
||||||
|
get: () => props.keyword,
|
||||||
|
set: (value: string) => emit('update:keyword', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const templateFilterModel = computed({
|
||||||
|
get: () => props.templateFilter,
|
||||||
|
set: (value: number | 'all') => emit('update:template-filter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusFilterModel = computed({
|
||||||
|
get: () => props.statusFilter,
|
||||||
|
set: (value: GiftCardCodeStatusFilter) => emit('update:status-filter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModel = computed({
|
||||||
|
get: () => props.current,
|
||||||
|
set: (value: number) => emit('update:current', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageSizeModel = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: (value: number) => emit('update:page-size', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
function isCodeEnabled(code: AdminGiftCardCodeItem): boolean {
|
||||||
|
return code.status !== 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodeToggleDisabled(code: AdminGiftCardCodeItem): boolean {
|
||||||
|
return code.status === 1 || code.status === 2
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="panel-copy">
|
||||||
|
<h2>兑换码管理</h2>
|
||||||
|
<p>管理礼品卡兑换码,包括生成、查看和导出兑换码。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<ElInput v-model="keywordModel" clearable placeholder="搜索礼品卡..." class="toolbar-search">
|
||||||
|
<template #prefix><ElIcon><Search /></ElIcon></template>
|
||||||
|
</ElInput>
|
||||||
|
|
||||||
|
<ElSelect v-model="templateFilterModel" class="toolbar-filter">
|
||||||
|
<ElOption label="模板" value="all" />
|
||||||
|
<ElOption v-for="item in templates" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<ElSelect v-model="statusFilterModel" class="toolbar-filter">
|
||||||
|
<ElOption label="状态" value="all" />
|
||||||
|
<ElOption
|
||||||
|
v-for="item in [
|
||||||
|
{ label: '未使用', value: 0 },
|
||||||
|
{ label: '已使用', value: 1 },
|
||||||
|
{ label: '已过期', value: 2 },
|
||||||
|
{ label: '已禁用', value: 3 },
|
||||||
|
]"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<ElButton type="primary" @click="emit('create')">
|
||||||
|
<ElIcon><Plus /></ElIcon>
|
||||||
|
生成兑换码
|
||||||
|
</ElButton>
|
||||||
|
<ElButton :disabled="!resolvedBatchId" @click="emit('export')">
|
||||||
|
<ElIcon><Download /></ElIcon>
|
||||||
|
导出
|
||||||
|
</ElButton>
|
||||||
|
<ElButton @click="emit('reset')">重置</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resolvedBatchId" class="batch-tip">
|
||||||
|
当前批次:
|
||||||
|
<button type="button" class="batch-pill batch-pill--active" @click="emit('select-batch', resolvedBatchId)">
|
||||||
|
{{ resolvedBatchId }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
|
||||||
|
|
||||||
|
<ElTable :data="codes" v-loading="loading" class="data-table" row-key="id" empty-text="当前筛选条件下暂无兑换码">
|
||||||
|
<ElTableColumn prop="id" label="ID" width="88" />
|
||||||
|
<ElTableColumn label="兑换码" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="code-cell">
|
||||||
|
<div class="code-line">
|
||||||
|
<strong class="mono">{{ row.code }}</strong>
|
||||||
|
<ElButton text @click="emit('copy', row.code)"><ElIcon><CopyDocument /></ElIcon></ElButton>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="batch-pill" @click="emit('select-batch', row.batch_id || '')">
|
||||||
|
{{ row.batch_id || '无批次' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="template_name" label="模板名称" min-width="160" />
|
||||||
|
<ElTableColumn label="状态" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="status-with-switch">
|
||||||
|
<span class="pill" :class="`pill--${getGiftCardCodeStatusMeta(row.status).tone}`">
|
||||||
|
{{ getGiftCardCodeStatusMeta(row.status).label }}
|
||||||
|
</span>
|
||||||
|
<ElSwitch
|
||||||
|
:model-value="isCodeEnabled(row)"
|
||||||
|
:disabled="isCodeToggleDisabled(row)"
|
||||||
|
@change="emit('toggle', row, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="过期时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatGiftCardDateTime(row.expires_at) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="已用/总次数" width="140">
|
||||||
|
<template #default="{ row }">{{ row.usage_count }} / {{ row.max_usage }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="可用次数" width="110">
|
||||||
|
<template #default="{ row }">{{ getGiftCardAvailableUsage(row) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="创建时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatGiftCardDateTime(row.created_at) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-group">
|
||||||
|
<ElButton text @click="emit('edit', row)"><ElIcon><EditPen /></ElIcon></ElButton>
|
||||||
|
<ElButton text class="danger-btn" @click="emit('delete', row)"><ElIcon><Delete /></ElIcon></ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<footer class="table-footer">
|
||||||
|
<span>已选择 0 项,共 {{ total }} 项</span>
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="currentModel"
|
||||||
|
v-model:page-size="pageSizeModel"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AdminGiftCardStatistics } from '@/types/api'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
statistics: AdminGiftCardStatistics | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statsCards = computed(() => {
|
||||||
|
const total = props.statistics?.total_stats
|
||||||
|
return [
|
||||||
|
{ label: '模板总数', value: total?.templates_count ?? 0 },
|
||||||
|
{ label: '活跃模板数', value: total?.active_templates_count ?? 0 },
|
||||||
|
{ label: '兑换码总数', value: total?.codes_count ?? 0 },
|
||||||
|
{ label: '已使用兑换码', value: total?.used_codes_count ?? 0 },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="panel-copy">
|
||||||
|
<h2>统计数据</h2>
|
||||||
|
<p>查看礼品卡的统计数据和使用情况分析。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
|
||||||
|
|
||||||
|
<div class="stats-grid" v-loading="loading">
|
||||||
|
<article v-for="item in statsCards" :key="item.label" class="stats-card">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-secondary" v-if="statistics">
|
||||||
|
<article class="stats-list-card">
|
||||||
|
<header>
|
||||||
|
<strong>最近 30 天使用走势</strong>
|
||||||
|
<span>按天汇总兑换使用次数</span>
|
||||||
|
</header>
|
||||||
|
<ul v-if="statistics.daily_usages.length > 0">
|
||||||
|
<li v-for="item in statistics.daily_usages.slice(-7)" :key="item.date">
|
||||||
|
<span>{{ item.date }}</span>
|
||||||
|
<strong>{{ item.count }}</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="empty-copy">最近 30 天暂无使用记录</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="stats-list-card">
|
||||||
|
<header>
|
||||||
|
<strong>模板消耗排行</strong>
|
||||||
|
<span>按模板汇总兑换次数</span>
|
||||||
|
</header>
|
||||||
|
<ul v-if="statistics.type_stats.length > 0">
|
||||||
|
<li v-for="item in statistics.type_stats.slice(0, 6)" :key="`${item.template_name}-${item.type_name}`">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.template_name || '未命名模板' }}</strong>
|
||||||
|
<span>{{ item.type_name }}</span>
|
||||||
|
</div>
|
||||||
|
<b>{{ item.count }}</b>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="empty-copy">暂无模板使用数据</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
.template-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--xboard-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card header .el-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--xboard-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card header div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card header strong {
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card header span,
|
||||||
|
.switch-box span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-columns {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input,
|
||||||
|
.date-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-box strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-box--soft {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--xboard-surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.two-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Aim,
|
||||||
|
CircleCheck,
|
||||||
|
Clock,
|
||||||
|
Picture,
|
||||||
|
Present,
|
||||||
|
Setting,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
createGiftCardTemplate,
|
||||||
|
updateGiftCardTemplate,
|
||||||
|
} from '@/api/admin'
|
||||||
|
import type {
|
||||||
|
AdminGiftCardTemplateItem,
|
||||||
|
AdminPlanOption,
|
||||||
|
} from '@/types/api'
|
||||||
|
import {
|
||||||
|
buildGiftCardTypeOptions,
|
||||||
|
createGiftCardTemplateFormModel,
|
||||||
|
toGiftCardTemplateFormModel,
|
||||||
|
toGiftCardTemplatePayload,
|
||||||
|
type GiftCardOption,
|
||||||
|
type GiftCardTemplateFormModel,
|
||||||
|
} from '@/utils/giftCards'
|
||||||
|
|
||||||
|
type DrawerMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
mode: DrawerMode
|
||||||
|
template?: AdminGiftCardTemplateItem | null
|
||||||
|
plans: AdminPlanOption[]
|
||||||
|
typeMap?: Record<string, string> | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', value: boolean): void
|
||||||
|
(e: 'success'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const saving = ref(false)
|
||||||
|
const form = reactive<GiftCardTemplateFormModel>(createGiftCardTemplateFormModel())
|
||||||
|
|
||||||
|
const title = computed(() => props.mode === 'edit' ? '编辑模板' : '添加模板')
|
||||||
|
const typeOptions = computed<GiftCardOption<1 | 2 | 3>[]>(() => buildGiftCardTypeOptions(props.typeMap))
|
||||||
|
|
||||||
|
const rules: FormRules<GiftCardTemplateFormModel> = {
|
||||||
|
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||||
|
type: [{ required: true, message: '请选择模板类型', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchForm(nextForm: GiftCardTemplateFormModel) {
|
||||||
|
Object.assign(form, createGiftCardTemplateFormModel(), nextForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.type === 2 && !form.plan_id) {
|
||||||
|
ElMessage.warning('套餐礼品卡需要先选择目标套餐')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = toGiftCardTemplatePayload(form)
|
||||||
|
if (props.mode === 'edit' && form.id) {
|
||||||
|
await updateGiftCardTemplate({ ...payload, id: form.id })
|
||||||
|
ElMessage.success('礼品卡模板已更新')
|
||||||
|
} else {
|
||||||
|
await createGiftCardTemplate(payload)
|
||||||
|
ElMessage.success('礼品卡模板已创建')
|
||||||
|
}
|
||||||
|
emit('success')
|
||||||
|
closeDrawer()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '礼品卡模板保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patchForm(toGiftCardTemplateFormModel(props.template))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDrawer
|
||||||
|
:model-value="visible"
|
||||||
|
:title="title"
|
||||||
|
size="620px"
|
||||||
|
direction="rtl"
|
||||||
|
class="gift-card-template-drawer"
|
||||||
|
@update:model-value="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
class="template-form"
|
||||||
|
>
|
||||||
|
<section class="section-card">
|
||||||
|
<header>
|
||||||
|
<ElIcon><Setting /></ElIcon>
|
||||||
|
<div>
|
||||||
|
<strong>基础配置</strong>
|
||||||
|
<span>管理模板名称、类型与基础开关。</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-grid two-columns">
|
||||||
|
<ElFormItem label="模板名称" prop="name">
|
||||||
|
<ElInput v-model="form.name" placeholder="请输入模板名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="类型" prop="type">
|
||||||
|
<ElSelect v-model="form.type" placeholder="请选择模板类型">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in typeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="描述" class="full-row">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请输入礼品卡描述"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="排序">
|
||||||
|
<ElInputNumber v-model="form.sort" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="状态">
|
||||||
|
<div class="switch-box">
|
||||||
|
<div>
|
||||||
|
<strong>{{ form.status ? '启用中' : '已停用' }}</strong>
|
||||||
|
<span>禁用后,此模板将无法生成或兑换新的礼品卡。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="form.status" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<header>
|
||||||
|
<ElIcon><Present /></ElIcon>
|
||||||
|
<div>
|
||||||
|
<strong>奖励内容</strong>
|
||||||
|
<span>配置余额、流量、设备数与套餐奖励。</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-grid two-columns">
|
||||||
|
<ElFormItem label="奖励余额(元)">
|
||||||
|
<ElInputNumber v-model="form.balance_yuan" :min="0" :precision="2" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="奖励流量(GB)">
|
||||||
|
<ElInputNumber v-model="form.transfer_gb" :min="0" :precision="2" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="延长有效期(天)">
|
||||||
|
<ElInputNumber v-model="form.expire_days" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="增加设备数">
|
||||||
|
<ElInputNumber v-model="form.device_limit" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="邀请人奖励比例" class="full-row">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.invite_reward_rate"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.05"
|
||||||
|
:precision="2"
|
||||||
|
class="number-input"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="重置当月流量" class="full-row">
|
||||||
|
<div class="switch-box switch-box--soft">
|
||||||
|
<div>
|
||||||
|
<strong>开启后,将给符合条件的用户直接重置当月流量。</strong>
|
||||||
|
<span>适合流量补偿、活动回馈和节假日赠送场景。</span>
|
||||||
|
</div>
|
||||||
|
<ElSwitch v-model="form.reset_package" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<template v-if="form.type === 2">
|
||||||
|
<ElFormItem label="目标套餐">
|
||||||
|
<ElSelect v-model="form.plan_id" placeholder="请选择礼包对应套餐">
|
||||||
|
<ElOption
|
||||||
|
v-for="plan in plans"
|
||||||
|
:key="plan.id"
|
||||||
|
:label="plan.name"
|
||||||
|
:value="plan.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="套餐有效期(天)">
|
||||||
|
<ElInputNumber v-model="form.plan_validity_days" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<header>
|
||||||
|
<ElIcon><Aim /></ElIcon>
|
||||||
|
<div>
|
||||||
|
<strong>使用条件</strong>
|
||||||
|
<span>限制哪些用户可以兑换该模板。</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-grid two-columns">
|
||||||
|
<ElFormItem label="新用户注册天数限制">
|
||||||
|
<ElInputNumber v-model="form.new_user_max_days" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="允许的套餐">
|
||||||
|
<ElSelect
|
||||||
|
v-model="form.allowed_plan_ids"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="选择允许兑换的套餐(留空则不限)"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="plan in plans"
|
||||||
|
:key="plan.id"
|
||||||
|
:label="plan.name"
|
||||||
|
:value="plan.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="仅限新用户">
|
||||||
|
<ElSwitch v-model="form.new_user_only" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="仅限付费用户">
|
||||||
|
<ElSwitch v-model="form.paid_user_only" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="需要邀请关系">
|
||||||
|
<ElSwitch v-model="form.require_invite" />
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<header>
|
||||||
|
<ElIcon><CircleCheck /></ElIcon>
|
||||||
|
<div>
|
||||||
|
<strong>使用限制</strong>
|
||||||
|
<span>限制单用户可使用次数和冷却时间。</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-grid two-columns">
|
||||||
|
<ElFormItem label="单用户最大使用次数">
|
||||||
|
<ElInputNumber v-model="form.max_use_per_user" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="同类卡冷却时间(小时)">
|
||||||
|
<ElInputNumber v-model="form.cooldown_hours" :min="0" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<header>
|
||||||
|
<ElIcon><Clock /></ElIcon>
|
||||||
|
<div>
|
||||||
|
<strong>特殊配置</strong>
|
||||||
|
<span>用于配置节日活动与倍率加成。</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-grid two-columns">
|
||||||
|
<ElFormItem label="活动开始时间">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="form.festival_start_at"
|
||||||
|
type="datetime"
|
||||||
|
value-format="X"
|
||||||
|
class="date-input"
|
||||||
|
placeholder="请选择开始时间"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="活动结束时间">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="form.festival_end_at"
|
||||||
|
type="datetime"
|
||||||
|
value-format="X"
|
||||||
|
class="date-input"
|
||||||
|
placeholder="请选择结束时间"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="节日奖励系数" class="full-row">
|
||||||
|
<ElInputNumber v-model="form.festival_bonus" :min="0" :precision="2" :step="0.1" class="number-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<header>
|
||||||
|
<ElIcon><Picture /></ElIcon>
|
||||||
|
<div>
|
||||||
|
<strong>显示效果</strong>
|
||||||
|
<span>管理卡片 icon、背景图与主题色。</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-grid two-columns">
|
||||||
|
<ElFormItem label="图标 URL">
|
||||||
|
<ElInput v-model="form.icon" placeholder="请输入图标的 URL" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="背景图片 URL">
|
||||||
|
<ElInput v-model="form.background_image" placeholder="请输入背景图片的 URL" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="主题色" class="full-row">
|
||||||
|
<div class="color-row">
|
||||||
|
<ElColorPicker v-model="form.theme_color" />
|
||||||
|
<ElInput v-model="form.theme_color" placeholder="#0071e3" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<ElButton @click="closeDrawer">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="saving" @click="handleSubmit">
|
||||||
|
{{ mode === 'edit' ? '保存修改' : '创建模板' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" src="./GiftCardTemplateDrawer.scss"></style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Delete, EditPen, Plus, Search } from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
AdminGiftCardTemplateItem,
|
||||||
|
AdminGiftCardTemplateType,
|
||||||
|
AdminPlanOption,
|
||||||
|
} from '@/types/api'
|
||||||
|
import type { GiftCardOption, GiftCardTemplateStatusFilter } from '@/utils/giftCards'
|
||||||
|
import {
|
||||||
|
formatGiftCardDateTime,
|
||||||
|
getGiftCardTemplateRewardSummary,
|
||||||
|
} from '@/utils/giftCards'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
templates: AdminGiftCardTemplateItem[]
|
||||||
|
keyword: string
|
||||||
|
typeFilter: AdminGiftCardTemplateType | 'all'
|
||||||
|
statusFilter: GiftCardTemplateStatusFilter
|
||||||
|
current: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
typeOptions: Array<GiftCardOption<AdminGiftCardTemplateType>>
|
||||||
|
plans: AdminPlanOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:keyword', value: string): void
|
||||||
|
(e: 'update:type-filter', value: AdminGiftCardTemplateType | 'all'): void
|
||||||
|
(e: 'update:status-filter', value: GiftCardTemplateStatusFilter): void
|
||||||
|
(e: 'update:current', value: number): void
|
||||||
|
(e: 'update:page-size', value: number): void
|
||||||
|
(e: 'create'): void
|
||||||
|
(e: 'reset'): void
|
||||||
|
(e: 'edit', template: AdminGiftCardTemplateItem): void
|
||||||
|
(e: 'delete', template: AdminGiftCardTemplateItem): void
|
||||||
|
(e: 'toggle', template: AdminGiftCardTemplateItem, nextValue: string | number | boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const keywordModel = computed({
|
||||||
|
get: () => props.keyword,
|
||||||
|
set: (value: string) => emit('update:keyword', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeFilterModel = computed({
|
||||||
|
get: () => props.typeFilter,
|
||||||
|
set: (value: AdminGiftCardTemplateType | 'all') => emit('update:type-filter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusFilterModel = computed({
|
||||||
|
get: () => props.statusFilter,
|
||||||
|
set: (value: GiftCardTemplateStatusFilter) => emit('update:status-filter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModel = computed({
|
||||||
|
get: () => props.current,
|
||||||
|
set: (value: number) => emit('update:current', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageSizeModel = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: (value: number) => emit('update:page-size', value),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="panel-copy">
|
||||||
|
<h2>模板管理</h2>
|
||||||
|
<p>管理礼品卡模板,包括创建、编辑和删除模板。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<ElInput v-model="keywordModel" clearable placeholder="搜索礼品卡..." class="toolbar-search">
|
||||||
|
<template #prefix><ElIcon><Search /></ElIcon></template>
|
||||||
|
</ElInput>
|
||||||
|
|
||||||
|
<ElSelect v-model="typeFilterModel" class="toolbar-filter">
|
||||||
|
<ElOption label="类型" value="all" />
|
||||||
|
<ElOption v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<ElSelect v-model="statusFilterModel" class="toolbar-filter">
|
||||||
|
<ElOption label="状态" value="all" />
|
||||||
|
<ElOption label="启用中" value="enabled" />
|
||||||
|
<ElOption label="已停用" value="disabled" />
|
||||||
|
</ElSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<ElButton type="primary" @click="emit('create')">
|
||||||
|
<ElIcon><Plus /></ElIcon>
|
||||||
|
添加模板
|
||||||
|
</ElButton>
|
||||||
|
<ElButton @click="emit('reset')">重置</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
|
||||||
|
|
||||||
|
<ElTable :data="templates" v-loading="loading" class="data-table" row-key="id" empty-text="当前筛选条件下暂无模板">
|
||||||
|
<ElTableColumn prop="id" label="ID" width="88" />
|
||||||
|
<ElTableColumn label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElSwitch :model-value="Boolean(row.status)" @change="emit('toggle', row, $event)" />
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="名称" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="name-cell">
|
||||||
|
<strong>{{ row.name }}</strong>
|
||||||
|
<span>{{ row.description || '暂无描述' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="类型" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="pill pill--soft">{{ row.type_name }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="奖励内容" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="reward-stack">
|
||||||
|
<span v-for="item in getGiftCardTemplateRewardSummary(row, plans)" :key="item" class="reward-chip">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="sort" label="排序" width="90" />
|
||||||
|
<ElTableColumn label="创建时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatGiftCardDateTime(row.created_at) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-group">
|
||||||
|
<ElButton text @click="emit('edit', row)"><ElIcon><EditPen /></ElIcon></ElButton>
|
||||||
|
<ElButton text class="danger-btn" @click="emit('delete', row)"><ElIcon><Delete /></ElIcon></ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<footer class="table-footer">
|
||||||
|
<span>已选择 0 项,共 {{ total }} 项</span>
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="currentModel"
|
||||||
|
v-model:page-size="pageSizeModel"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { RefreshRight, Search } from '@element-plus/icons-vue'
|
||||||
|
import type { AdminGiftCardUsageItem } from '@/types/api'
|
||||||
|
import { formatGiftCardDateTime, formatGiftCardMultiplier } from '@/utils/giftCards'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
usages: AdminGiftCardUsageItem[]
|
||||||
|
keyword: string
|
||||||
|
current: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:keyword', value: string): void
|
||||||
|
(e: 'update:current', value: number): void
|
||||||
|
(e: 'update:page-size', value: number): void
|
||||||
|
(e: 'reset'): void
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const keywordModel = computed({
|
||||||
|
get: () => props.keyword,
|
||||||
|
set: (value: string) => emit('update:keyword', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModel = computed({
|
||||||
|
get: () => props.current,
|
||||||
|
set: (value: number) => emit('update:current', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageSizeModel = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: (value: number) => emit('update:page-size', value),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="panel-copy">
|
||||||
|
<h2>使用记录</h2>
|
||||||
|
<p>查看礼品卡的使用记录和详细信息。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<ElInput v-model="keywordModel" clearable placeholder="搜索用户邮箱..." class="toolbar-search">
|
||||||
|
<template #prefix><ElIcon><Search /></ElIcon></template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<ElButton @click="emit('reset')">重置</ElButton>
|
||||||
|
<ElButton @click="emit('refresh')">
|
||||||
|
<ElIcon><RefreshRight /></ElIcon>
|
||||||
|
刷新
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
|
||||||
|
|
||||||
|
<ElTable :data="usages" v-loading="loading" class="data-table" row-key="id" empty-text="暂无数据">
|
||||||
|
<ElTableColumn prop="id" label="ID" width="88" />
|
||||||
|
<ElTableColumn prop="code" label="兑换码" min-width="240">
|
||||||
|
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="user_email" label="用户邮箱" min-width="220" />
|
||||||
|
<ElTableColumn prop="template_name" label="模板名称" min-width="180" />
|
||||||
|
<ElTableColumn label="使用倍率" width="120">
|
||||||
|
<template #default="{ row }">{{ formatGiftCardMultiplier(row.multiplier_applied) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="使用时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatGiftCardDateTime(row.created_at) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<footer class="table-footer">
|
||||||
|
<span>已选择 0 项,共 {{ total }} 项</span>
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="currentModel"
|
||||||
|
v-model:page-size="pageSizeModel"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
.gift-cards-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-copy h1 {
|
||||||
|
font-size: clamp(34px, 5vw, 48px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-copy p,
|
||||||
|
.panel-copy p,
|
||||||
|
.table-footer span,
|
||||||
|
.empty-copy,
|
||||||
|
.stats-list-card header span,
|
||||||
|
.name-cell span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
line-height: 1.47;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-shell,
|
||||||
|
.tab-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #e9edf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-tab {
|
||||||
|
height: 44px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-tab.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-copy h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-right,
|
||||||
|
.table-footer,
|
||||||
|
.action-group,
|
||||||
|
.status-with-switch,
|
||||||
|
.code-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.table-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-filter {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th.el-table__cell {
|
||||||
|
background: #fbfbfd;
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table .el-table__row td.el-table__cell {
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell strong,
|
||||||
|
.stats-list-card strong {
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-chip,
|
||||||
|
.batch-pill,
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-chip {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--success {
|
||||||
|
background: rgba(35, 134, 63, 0.12);
|
||||||
|
color: var(--xboard-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--warning {
|
||||||
|
background: rgba(176, 90, 0, 0.12);
|
||||||
|
color: var(--xboard-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--danger {
|
||||||
|
background: rgba(201, 52, 40, 0.12);
|
||||||
|
color: var(--xboard-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--info {
|
||||||
|
background: rgba(0, 113, 227, 0.12);
|
||||||
|
color: var(--xboard-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill--soft {
|
||||||
|
background: rgba(0, 113, 227, 0.08);
|
||||||
|
color: var(--xboard-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-line {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-pill {
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-pill--active {
|
||||||
|
background: rgba(0, 113, 227, 0.12);
|
||||||
|
color: var(--xboard-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn {
|
||||||
|
color: var(--xboard-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card,
|
||||||
|
.stats-list-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--xboard-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card span {
|
||||||
|
color: var(--xboard-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card strong {
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--xboard-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-secondary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-card header {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-card ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-card li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--xboard-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-card li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-card li div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-card li div span {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-edit-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-secondary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.segment-tabs {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.table-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.stats-grid,
|
||||||
|
.segment-tabs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-filter,
|
||||||
|
.toolbar-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { updateGiftCardCode } from '@/api/admin'
|
||||||
|
import type { AdminGiftCardCodeItem, AdminGiftCardCodeStatus } from '@/types/api'
|
||||||
|
import GiftCardCodeBatchDialog from './GiftCardCodeBatchDialog.vue'
|
||||||
|
import GiftCardCodesTab from './GiftCardCodesTab.vue'
|
||||||
|
import GiftCardStatsTab from './GiftCardStatsTab.vue'
|
||||||
|
import GiftCardTemplateDrawer from './GiftCardTemplateDrawer.vue'
|
||||||
|
import GiftCardTemplatesTab from './GiftCardTemplatesTab.vue'
|
||||||
|
import GiftCardUsagesTab from './GiftCardUsagesTab.vue'
|
||||||
|
import { useGiftCardsManagement } from './useGiftCardsManagement'
|
||||||
|
|
||||||
|
const vm = reactive(useGiftCardsManagement())
|
||||||
|
const codeEditorVisible = ref(false)
|
||||||
|
const codeSaving = ref(false)
|
||||||
|
const codeForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
expires_at: '' as string | number,
|
||||||
|
max_usage: 1,
|
||||||
|
status: 0 as AdminGiftCardCodeStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCodeEditor(code: AdminGiftCardCodeItem) {
|
||||||
|
codeForm.id = code.id
|
||||||
|
codeForm.max_usage = code.max_usage
|
||||||
|
codeForm.status = code.status
|
||||||
|
codeForm.expires_at = code.expires_at ? String(Number(code.expires_at)) : ''
|
||||||
|
codeEditorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCodeEdit() {
|
||||||
|
codeSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateGiftCardCode({
|
||||||
|
id: codeForm.id,
|
||||||
|
expires_at: codeForm.expires_at ? Number(codeForm.expires_at) : null,
|
||||||
|
max_usage: codeForm.max_usage,
|
||||||
|
status: codeForm.status,
|
||||||
|
})
|
||||||
|
ElMessage.success('兑换码信息已更新')
|
||||||
|
codeEditorVisible.value = false
|
||||||
|
await Promise.all([vm.loadCodes(), vm.loadStatistics()])
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '兑换码更新失败')
|
||||||
|
} finally {
|
||||||
|
codeSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gift-cards-page">
|
||||||
|
<section class="page-intro">
|
||||||
|
<div class="intro-copy">
|
||||||
|
<h1>礼品卡管理</h1>
|
||||||
|
<p>在这里可以管理礼品卡模板、兑换码和使用记录等功能。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="segment-shell">
|
||||||
|
<div class="segment-tabs">
|
||||||
|
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'templates' }]" @click="vm.activeTab = 'templates'">模板管理</button>
|
||||||
|
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'codes' }]" @click="vm.activeTab = 'codes'">兑换码管理</button>
|
||||||
|
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'usages' }]" @click="vm.activeTab = 'usages'">使用记录</button>
|
||||||
|
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'statistics' }]" @click="vm.activeTab = 'statistics'">统计数据</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GiftCardTemplatesTab
|
||||||
|
v-if="vm.activeTab === 'templates'"
|
||||||
|
:loading="vm.templateLoading"
|
||||||
|
:error="vm.templateError"
|
||||||
|
:templates="vm.visibleTemplates"
|
||||||
|
:keyword="vm.templateKeyword"
|
||||||
|
:type-filter="vm.templateTypeFilter"
|
||||||
|
:status-filter="vm.templateStatusFilter"
|
||||||
|
:current="vm.templateCurrent"
|
||||||
|
:page-size="vm.templatePageSize"
|
||||||
|
:total="vm.filteredTemplates.length"
|
||||||
|
:type-options="vm.typeOptions"
|
||||||
|
:plans="vm.plans"
|
||||||
|
@update:keyword="vm.templateKeyword = $event"
|
||||||
|
@update:type-filter="vm.templateTypeFilter = $event"
|
||||||
|
@update:status-filter="vm.templateStatusFilter = $event"
|
||||||
|
@update:current="vm.templateCurrent = $event"
|
||||||
|
@update:page-size="vm.templatePageSize = $event"
|
||||||
|
@create="vm.openCreateTemplate"
|
||||||
|
@reset="vm.resetTemplateFilters"
|
||||||
|
@edit="vm.openEditTemplate"
|
||||||
|
@delete="vm.handleTemplateDelete"
|
||||||
|
@toggle="vm.handleTemplateToggle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GiftCardCodesTab
|
||||||
|
v-else-if="vm.activeTab === 'codes'"
|
||||||
|
:loading="vm.codesLoading"
|
||||||
|
:error="vm.codesError"
|
||||||
|
:codes="vm.visibleCodes"
|
||||||
|
:keyword="vm.codeKeyword"
|
||||||
|
:template-filter="vm.codeTemplateFilter"
|
||||||
|
:status-filter="vm.codeStatusFilter"
|
||||||
|
:current="vm.codeCurrent"
|
||||||
|
:page-size="vm.codePageSize"
|
||||||
|
:total="vm.filteredCodes.length"
|
||||||
|
:templates="vm.templates"
|
||||||
|
:resolved-batch-id="vm.resolvedBatchId"
|
||||||
|
@update:keyword="vm.codeKeyword = $event"
|
||||||
|
@update:template-filter="vm.codeTemplateFilter = $event"
|
||||||
|
@update:status-filter="vm.codeStatusFilter = $event"
|
||||||
|
@update:current="vm.codeCurrent = $event"
|
||||||
|
@update:page-size="vm.codePageSize = $event"
|
||||||
|
@create="vm.batchDialogVisible = true"
|
||||||
|
@export="vm.handleExportBatch"
|
||||||
|
@reset="vm.resetCodeFilters"
|
||||||
|
@copy="vm.copyCode"
|
||||||
|
@select-batch="vm.setSelectedBatchId($event)"
|
||||||
|
@edit="openCodeEditor"
|
||||||
|
@delete="vm.handleCodeDelete"
|
||||||
|
@toggle="vm.handleCodeToggle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GiftCardUsagesTab
|
||||||
|
v-else-if="vm.activeTab === 'usages'"
|
||||||
|
:loading="vm.usagesLoading"
|
||||||
|
:error="vm.usagesError"
|
||||||
|
:usages="vm.visibleUsages"
|
||||||
|
:keyword="vm.usageKeyword"
|
||||||
|
:current="vm.usageCurrent"
|
||||||
|
:page-size="vm.usagePageSize"
|
||||||
|
:total="vm.filteredUsages.length"
|
||||||
|
@update:keyword="vm.usageKeyword = $event"
|
||||||
|
@update:current="vm.usageCurrent = $event"
|
||||||
|
@update:page-size="vm.usagePageSize = $event"
|
||||||
|
@reset="vm.resetUsageFilters"
|
||||||
|
@refresh="vm.loadUsages"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GiftCardStatsTab
|
||||||
|
v-else
|
||||||
|
:loading="vm.statsLoading"
|
||||||
|
:error="vm.statsError"
|
||||||
|
:statistics="vm.statistics"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<GiftCardTemplateDrawer
|
||||||
|
v-model:visible="vm.templateDrawerVisible"
|
||||||
|
:mode="vm.templateDrawerMode"
|
||||||
|
:template="vm.activeTemplate"
|
||||||
|
:plans="vm.plans"
|
||||||
|
:type-map="vm.typeMap"
|
||||||
|
@success="vm.handleTemplateSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GiftCardCodeBatchDialog
|
||||||
|
v-model:visible="vm.batchDialogVisible"
|
||||||
|
:templates="vm.templates.filter((item) => Boolean(item.status))"
|
||||||
|
@success="vm.handleBatchGenerated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElDialog v-model="codeEditorVisible" title="编辑兑换码" width="440px">
|
||||||
|
<ElForm label-position="top" class="code-edit-form">
|
||||||
|
<ElFormItem label="过期时间">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="codeForm.expires_at"
|
||||||
|
type="datetime"
|
||||||
|
value-format="X"
|
||||||
|
class="full-width"
|
||||||
|
placeholder="留空则长期有效"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="最大使用次数">
|
||||||
|
<ElInputNumber v-model="codeForm.max_usage" :min="1" :max="1000" class="full-width" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="状态">
|
||||||
|
<ElSelect v-model="codeForm.status" class="full-width">
|
||||||
|
<ElOption label="未使用" :value="0" />
|
||||||
|
<ElOption label="已使用" :value="1" />
|
||||||
|
<ElOption label="已过期" :value="2" />
|
||||||
|
<ElOption label="已禁用" :value="3" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="codeEditorVisible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="codeSaving" @click="submitCodeEdit">保存修改</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" src="./GiftCardsView.scss"></style>
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
deleteGiftCardCode,
|
||||||
|
deleteGiftCardTemplate,
|
||||||
|
exportGiftCardCodes,
|
||||||
|
fetchGiftCardCodes,
|
||||||
|
fetchGiftCardTemplates,
|
||||||
|
fetchGiftCardUsages,
|
||||||
|
getGiftCardStatistics,
|
||||||
|
getGiftCardTypes,
|
||||||
|
getPlans,
|
||||||
|
toggleGiftCardCode,
|
||||||
|
updateGiftCardTemplate,
|
||||||
|
} from '@/api/admin'
|
||||||
|
import type {
|
||||||
|
AdminGiftCardCodeItem,
|
||||||
|
AdminGiftCardStatistics,
|
||||||
|
AdminGiftCardTemplateItem,
|
||||||
|
AdminGiftCardTemplateType,
|
||||||
|
AdminGiftCardUsageItem,
|
||||||
|
AdminPlanOption,
|
||||||
|
} from '@/types/api'
|
||||||
|
import {
|
||||||
|
buildGiftCardTypeOptions,
|
||||||
|
filterGiftCardCodes,
|
||||||
|
filterGiftCardTemplates,
|
||||||
|
filterGiftCardUsages,
|
||||||
|
type GiftCardCodeStatusFilter,
|
||||||
|
type GiftCardTemplateStatusFilter,
|
||||||
|
} from '@/utils/giftCards'
|
||||||
|
|
||||||
|
export type GiftCardTabKey = 'templates' | 'codes' | 'usages' | 'statistics'
|
||||||
|
type TemplateDrawerMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
export function useGiftCardsManagement() {
|
||||||
|
const activeTab = ref<GiftCardTabKey>('templates')
|
||||||
|
|
||||||
|
const templateLoading = ref(false)
|
||||||
|
const codesLoading = ref(false)
|
||||||
|
const usagesLoading = ref(false)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
|
||||||
|
const templateError = ref('')
|
||||||
|
const codesError = ref('')
|
||||||
|
const usagesError = ref('')
|
||||||
|
const statsError = ref('')
|
||||||
|
|
||||||
|
const templates = ref<AdminGiftCardTemplateItem[]>([])
|
||||||
|
const codes = ref<AdminGiftCardCodeItem[]>([])
|
||||||
|
const usages = ref<AdminGiftCardUsageItem[]>([])
|
||||||
|
const statistics = ref<AdminGiftCardStatistics | null>(null)
|
||||||
|
const plans = ref<AdminPlanOption[]>([])
|
||||||
|
const typeMap = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const templateKeyword = ref('')
|
||||||
|
const templateTypeFilter = ref<AdminGiftCardTemplateType | 'all'>('all')
|
||||||
|
const templateStatusFilter = ref<GiftCardTemplateStatusFilter>('all')
|
||||||
|
const templateCurrent = ref(1)
|
||||||
|
const templatePageSize = ref(20)
|
||||||
|
|
||||||
|
const codeKeyword = ref('')
|
||||||
|
const codeTemplateFilter = ref<number | 'all'>('all')
|
||||||
|
const codeStatusFilter = ref<GiftCardCodeStatusFilter>('all')
|
||||||
|
const codeCurrent = ref(1)
|
||||||
|
const codePageSize = ref(20)
|
||||||
|
const selectedBatchId = ref('')
|
||||||
|
|
||||||
|
const usageKeyword = ref('')
|
||||||
|
const usageCurrent = ref(1)
|
||||||
|
const usagePageSize = ref(20)
|
||||||
|
|
||||||
|
const templateDrawerVisible = ref(false)
|
||||||
|
const templateDrawerMode = ref<TemplateDrawerMode>('create')
|
||||||
|
const activeTemplate = ref<AdminGiftCardTemplateItem | null>(null)
|
||||||
|
|
||||||
|
const batchDialogVisible = ref(false)
|
||||||
|
|
||||||
|
const typeOptions = computed(() => buildGiftCardTypeOptions(typeMap.value))
|
||||||
|
|
||||||
|
const filteredTemplates = computed(() => filterGiftCardTemplates(
|
||||||
|
templates.value,
|
||||||
|
templateKeyword.value,
|
||||||
|
templateTypeFilter.value,
|
||||||
|
templateStatusFilter.value,
|
||||||
|
))
|
||||||
|
|
||||||
|
const visibleTemplates = computed(() => {
|
||||||
|
const start = (templateCurrent.value - 1) * templatePageSize.value
|
||||||
|
return filteredTemplates.value.slice(start, start + templatePageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredCodes = computed(() => filterGiftCardCodes(
|
||||||
|
codes.value,
|
||||||
|
codeKeyword.value,
|
||||||
|
codeTemplateFilter.value,
|
||||||
|
codeStatusFilter.value,
|
||||||
|
))
|
||||||
|
|
||||||
|
const visibleCodes = computed(() => {
|
||||||
|
const start = (codeCurrent.value - 1) * codePageSize.value
|
||||||
|
return filteredCodes.value.slice(start, start + codePageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredUsages = computed(() => filterGiftCardUsages(usages.value, usageKeyword.value))
|
||||||
|
const visibleUsages = computed(() => {
|
||||||
|
const start = (usageCurrent.value - 1) * usagePageSize.value
|
||||||
|
return filteredUsages.value.slice(start, start + usagePageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvedBatchId = computed(() => {
|
||||||
|
if (selectedBatchId.value) {
|
||||||
|
return selectedBatchId.value
|
||||||
|
}
|
||||||
|
const batchIds = [...new Set(filteredCodes.value.map((item) => item.batch_id).filter(Boolean))]
|
||||||
|
return batchIds.length === 1 ? String(batchIds[0]) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadMeta() {
|
||||||
|
try {
|
||||||
|
const [plansResponse, typeResponse] = await Promise.all([
|
||||||
|
getPlans(),
|
||||||
|
getGiftCardTypes(),
|
||||||
|
])
|
||||||
|
plans.value = plansResponse.data ?? []
|
||||||
|
typeMap.value = typeResponse.data ?? {}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning(error instanceof Error ? error.message : '礼品卡元数据加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
templateLoading.value = true
|
||||||
|
templateError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await fetchGiftCardTemplates({
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
type: templateTypeFilter.value === 'all' ? undefined : templateTypeFilter.value,
|
||||||
|
status: templateStatusFilter.value === 'all'
|
||||||
|
? undefined
|
||||||
|
: (templateStatusFilter.value === 'enabled' ? 1 : 0),
|
||||||
|
})
|
||||||
|
templates.value = response.data ?? []
|
||||||
|
} catch (error) {
|
||||||
|
templateError.value = error instanceof Error ? error.message : '模板列表加载失败'
|
||||||
|
} finally {
|
||||||
|
templateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCodes() {
|
||||||
|
codesLoading.value = true
|
||||||
|
codesError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await fetchGiftCardCodes({
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
template_id: codeTemplateFilter.value === 'all' ? undefined : codeTemplateFilter.value,
|
||||||
|
status: codeStatusFilter.value === 'all' ? undefined : codeStatusFilter.value,
|
||||||
|
})
|
||||||
|
codes.value = response.data ?? []
|
||||||
|
|
||||||
|
if (selectedBatchId.value && !codes.value.some((item) => item.batch_id === selectedBatchId.value)) {
|
||||||
|
selectedBatchId.value = ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
codesError.value = error instanceof Error ? error.message : '兑换码列表加载失败'
|
||||||
|
} finally {
|
||||||
|
codesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsages() {
|
||||||
|
usagesLoading.value = true
|
||||||
|
usagesError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await fetchGiftCardUsages({
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
})
|
||||||
|
usages.value = response.data ?? []
|
||||||
|
} catch (error) {
|
||||||
|
usagesError.value = error instanceof Error ? error.message : '使用记录加载失败'
|
||||||
|
} finally {
|
||||||
|
usagesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatistics() {
|
||||||
|
statsLoading.value = true
|
||||||
|
statsError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getGiftCardStatistics()
|
||||||
|
statistics.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
statsError.value = error instanceof Error ? error.message : '统计数据加载失败'
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateTemplate() {
|
||||||
|
templateDrawerMode.value = 'create'
|
||||||
|
activeTemplate.value = null
|
||||||
|
templateDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTemplate(template: AdminGiftCardTemplateItem) {
|
||||||
|
templateDrawerMode.value = 'edit'
|
||||||
|
activeTemplate.value = template
|
||||||
|
templateDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTemplateToggle(template: AdminGiftCardTemplateItem, nextValue: string | number | boolean) {
|
||||||
|
const normalized = Boolean(nextValue)
|
||||||
|
if (Boolean(template.status) === normalized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateGiftCardTemplate({
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
type: template.type,
|
||||||
|
status: normalized,
|
||||||
|
rewards: template.rewards ?? {},
|
||||||
|
})
|
||||||
|
template.status = normalized
|
||||||
|
ElMessage.success('模板状态已更新')
|
||||||
|
await loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '模板状态更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTemplateDelete(template: AdminGiftCardTemplateItem) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除模板「${template.name}」后无法恢复,确认继续吗?`, '删除模板', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteGiftCardTemplate(template.id)
|
||||||
|
ElMessage.success('模板已删除')
|
||||||
|
await Promise.all([loadTemplates(), loadCodes(), loadStatistics()])
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '模板删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTemplateSuccess() {
|
||||||
|
await Promise.all([loadTemplates(), loadCodes(), loadStatistics()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCodeToggle(code: AdminGiftCardCodeItem, nextValue: string | number | boolean) {
|
||||||
|
const targetEnabled = Boolean(nextValue)
|
||||||
|
if (targetEnabled === (code.status !== 3)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await toggleGiftCardCode(code.id, targetEnabled ? 'enable' : 'disable')
|
||||||
|
code.status = targetEnabled ? 0 : 3
|
||||||
|
ElMessage.success(targetEnabled ? '兑换码已启用' : '兑换码已禁用')
|
||||||
|
await loadStatistics()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '兑换码状态更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCode(code: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
ElMessage.success('兑换码已复制')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCodeDelete(code: AdminGiftCardCodeItem) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除兑换码 ${code.code} 后无法恢复,确认继续吗?`, '删除兑换码', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteGiftCardCode(code.id)
|
||||||
|
ElMessage.success('兑换码已删除')
|
||||||
|
await Promise.all([loadCodes(), loadStatistics()])
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '兑换码删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportBatch() {
|
||||||
|
if (!resolvedBatchId.value) {
|
||||||
|
ElMessage.warning('请先选中一个批次后再导出')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await exportGiftCardCodes(resolvedBatchId.value)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `gift_cards_${resolvedBatchId.value}.txt`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('兑换码文本已导出')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '兑换码导出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchGenerated(payload: { batchId: string }) {
|
||||||
|
selectedBatchId.value = payload.batchId
|
||||||
|
void Promise.all([loadCodes(), loadStatistics()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTemplateFilters() {
|
||||||
|
templateKeyword.value = ''
|
||||||
|
templateTypeFilter.value = 'all'
|
||||||
|
templateStatusFilter.value = 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCodeFilters() {
|
||||||
|
codeKeyword.value = ''
|
||||||
|
codeTemplateFilter.value = 'all'
|
||||||
|
codeStatusFilter.value = 'all'
|
||||||
|
selectedBatchId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUsageFilters() {
|
||||||
|
usageKeyword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([templateKeyword, templateTypeFilter, templateStatusFilter, templatePageSize], () => {
|
||||||
|
templateCurrent.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([codeKeyword, codeTemplateFilter, codeStatusFilter, codePageSize], () => {
|
||||||
|
codeCurrent.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([usageKeyword, usagePageSize], () => {
|
||||||
|
usageCurrent.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([templateTypeFilter, templateStatusFilter], () => {
|
||||||
|
void loadTemplates()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([codeTemplateFilter, codeStatusFilter], () => {
|
||||||
|
void loadCodes()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void Promise.allSettled([
|
||||||
|
loadMeta(),
|
||||||
|
loadTemplates(),
|
||||||
|
loadCodes(),
|
||||||
|
loadUsages(),
|
||||||
|
loadStatistics(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
templateLoading,
|
||||||
|
codesLoading,
|
||||||
|
usagesLoading,
|
||||||
|
statsLoading,
|
||||||
|
templateError,
|
||||||
|
codesError,
|
||||||
|
usagesError,
|
||||||
|
statsError,
|
||||||
|
templates,
|
||||||
|
statistics,
|
||||||
|
plans,
|
||||||
|
typeMap,
|
||||||
|
typeOptions,
|
||||||
|
templateKeyword,
|
||||||
|
templateTypeFilter,
|
||||||
|
templateStatusFilter,
|
||||||
|
templateCurrent,
|
||||||
|
templatePageSize,
|
||||||
|
filteredTemplates,
|
||||||
|
visibleTemplates,
|
||||||
|
codeKeyword,
|
||||||
|
codeTemplateFilter,
|
||||||
|
codeStatusFilter,
|
||||||
|
codeCurrent,
|
||||||
|
codePageSize,
|
||||||
|
filteredCodes,
|
||||||
|
visibleCodes,
|
||||||
|
usageKeyword,
|
||||||
|
usageCurrent,
|
||||||
|
usagePageSize,
|
||||||
|
filteredUsages,
|
||||||
|
visibleUsages,
|
||||||
|
resolvedBatchId,
|
||||||
|
templateDrawerVisible,
|
||||||
|
templateDrawerMode,
|
||||||
|
activeTemplate,
|
||||||
|
batchDialogVisible,
|
||||||
|
loadUsages,
|
||||||
|
loadCodes,
|
||||||
|
loadStatistics,
|
||||||
|
openCreateTemplate,
|
||||||
|
openEditTemplate,
|
||||||
|
handleTemplateToggle,
|
||||||
|
handleTemplateDelete,
|
||||||
|
handleTemplateSuccess,
|
||||||
|
handleCodeToggle,
|
||||||
|
copyCode,
|
||||||
|
handleCodeDelete,
|
||||||
|
handleExportBatch,
|
||||||
|
handleBatchGenerated,
|
||||||
|
resetTemplateFilters,
|
||||||
|
resetCodeFilters,
|
||||||
|
resetUsageFilters,
|
||||||
|
setSelectedBatchId: (batchId: string) => {
|
||||||
|
selectedBatchId.value = batchId
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,7 +169,7 @@ watch(
|
|||||||
>
|
>
|
||||||
<div class="dialog-shell">
|
<div class="dialog-shell">
|
||||||
<div class="dialog-copy">
|
<div class="dialog-copy">
|
||||||
<p>Knowledge Base</p>
|
<p>系统管理</p>
|
||||||
<h2>{{ dialogTitle }}</h2>
|
<h2>{{ dialogTitle }}</h2>
|
||||||
<span>发布或维护知识库文案,支持分类、语言、显示状态和 Markdown 正文编辑。</span>
|
<span>发布或维护知识库文案,支持分类、语言、显示状态和 Markdown 正文编辑。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+43
-26
@@ -1,53 +1,45 @@
|
|||||||
.knowledge-page {
|
.knowledge-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-hero {
|
.knowledge-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 20px;
|
||||||
padding: 32px;
|
align-items: flex-start;
|
||||||
border-radius: 28px;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: var(--xboard-shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-copy {
|
.knowledge-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-kicker {
|
|
||||||
color: var(--xboard-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.24em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.knowledge-copy h1 {
|
.knowledge-copy h1 {
|
||||||
|
margin: 0;
|
||||||
font-size: clamp(34px, 5vw, 52px);
|
font-size: clamp(34px, 5vw, 52px);
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
letter-spacing: -0.28px;
|
letter-spacing: -0.28px;
|
||||||
color: var(--xboard-text-strong);
|
color: var(--xboard-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-copy span {
|
.knowledge-copy p {
|
||||||
|
margin: 0;
|
||||||
color: var(--xboard-text-secondary);
|
color: var(--xboard-text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 160px));
|
grid-template-columns: repeat(4, minmax(0, 128px));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats article {
|
.hero-stats article {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 18px;
|
padding: 16px 18px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: #fbfbfd;
|
background: #fbfbfd;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
@@ -66,9 +58,9 @@
|
|||||||
|
|
||||||
.table-shell {
|
.table-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
padding: 24px;
|
padding: 20px 20px 18px;
|
||||||
border-radius: 26px;
|
border-radius: 24px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-shadow: var(--xboard-shadow);
|
box-shadow: var(--xboard-shadow);
|
||||||
}
|
}
|
||||||
@@ -103,17 +95,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-search {
|
.toolbar-search {
|
||||||
width: min(320px, 100%);
|
width: min(260px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-filter {
|
.toolbar-filter {
|
||||||
width: 172px;
|
width: 148px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-alert {
|
.table-alert {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-table :deep(.el-table__cell) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
.knowledge-table :deep(th.el-table__cell) {
|
.knowledge-table :deep(th.el-table__cell) {
|
||||||
color: var(--xboard-text-secondary);
|
color: var(--xboard-text-secondary);
|
||||||
background: #fbfbfd;
|
background: #fbfbfd;
|
||||||
@@ -135,6 +131,8 @@
|
|||||||
.title-cell strong,
|
.title-cell strong,
|
||||||
.sort-meta strong {
|
.sort-meta strong {
|
||||||
color: var(--xboard-text-strong);
|
color: var(--xboard-text-strong);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-cell span,
|
.title-cell span,
|
||||||
@@ -144,15 +142,34 @@
|
|||||||
color: var(--xboard-text-muted);
|
color: var(--xboard-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-cell small {
|
||||||
|
color: var(--xboard-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
color: #0071e3;
|
||||||
|
background: rgba(0, 113, 227, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.danger-btn {
|
.danger-btn {
|
||||||
color: var(--xboard-danger);
|
color: var(--xboard-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger-btn:hover {
|
||||||
|
color: #d92d20;
|
||||||
|
background: rgba(217, 45, 32, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.sort-copy {
|
.sort-copy {
|
||||||
|
margin: 0;
|
||||||
line-height: 1.47;
|
line-height: 1.47;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +193,7 @@
|
|||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
.knowledge-hero,
|
.knowledge-hero,
|
||||||
|
.hero-stats,
|
||||||
.table-toolbar,
|
.table-toolbar,
|
||||||
.table-footer,
|
.table-footer,
|
||||||
.sort-item,
|
.sort-item,
|
||||||
@@ -187,16 +205,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.table-shell,
|
.table-shell,
|
||||||
.knowledge-hero {
|
.hero-stats {
|
||||||
padding: 22px;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-filter {
|
.toolbar-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,394 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SystemPlaceholderView from './SystemPlaceholderView.vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
Delete,
|
||||||
|
EditPen,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
deleteKnowledge,
|
||||||
|
getKnowledgeById,
|
||||||
|
getKnowledgeCategories,
|
||||||
|
getKnowledges,
|
||||||
|
sortKnowledges,
|
||||||
|
toggleKnowledgeVisibility,
|
||||||
|
} from '@/api/admin'
|
||||||
|
import type { AdminKnowledgeDetail, AdminKnowledgeListItem } from '@/types/api'
|
||||||
|
import { formatDateTime } from '@/utils/dashboard'
|
||||||
|
import {
|
||||||
|
countVisibleKnowledges,
|
||||||
|
filterKnowledges,
|
||||||
|
getKnowledgeCategoryLabel,
|
||||||
|
moveKnowledgeOrder,
|
||||||
|
normalizeKnowledgeCategories,
|
||||||
|
normalizeKnowledgeItem,
|
||||||
|
} from '@/utils/knowledge'
|
||||||
|
import KnowledgeEditorDialog from './KnowledgeEditorDialog.vue'
|
||||||
|
|
||||||
|
type EditorMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const sortSubmitting = ref(false)
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editorMode = ref<EditorMode>('create')
|
||||||
|
const activeKnowledge = ref<AdminKnowledgeDetail | null>(null)
|
||||||
|
const sortDialogVisible = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const categoryFilter = ref('')
|
||||||
|
const current = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const knowledges = ref<AdminKnowledgeListItem[]>([])
|
||||||
|
const categories = ref<string[]>([])
|
||||||
|
const sortDraft = ref<AdminKnowledgeListItem[]>([])
|
||||||
|
const toggleLoadingMap = ref<Record<number, boolean>>({})
|
||||||
|
const detailLoadingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const filteredKnowledges = computed(() => filterKnowledges(
|
||||||
|
knowledges.value,
|
||||||
|
keyword.value,
|
||||||
|
categoryFilter.value,
|
||||||
|
))
|
||||||
|
const visibleKnowledges = computed(() => {
|
||||||
|
const start = (current.value - 1) * pageSize.value
|
||||||
|
return filteredKnowledges.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
const heroStats = computed(() => [
|
||||||
|
{ label: '知识总数', value: String(knowledges.value.length) },
|
||||||
|
{ label: '显示中', value: String(countVisibleKnowledges(knowledges.value)) },
|
||||||
|
{ label: '隐藏中', value: String(Math.max(knowledges.value.length - countVisibleKnowledges(knowledges.value), 0)) },
|
||||||
|
{ label: '分类数', value: String(categories.value.length) },
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeKnowledgeDetail(detail: AdminKnowledgeDetail): AdminKnowledgeDetail {
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
category: typeof detail.category === 'string' ? detail.category.trim() : detail.category,
|
||||||
|
show: Boolean(detail.show),
|
||||||
|
body: detail.body || '',
|
||||||
|
language: detail.language || 'zh-CN',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToggleLoading(id: number): boolean {
|
||||||
|
return Boolean(toggleLoadingMap.value[id])
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDetailLoading(id: number): boolean {
|
||||||
|
return detailLoadingId.value === id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const knowledgeResponse = await getKnowledges()
|
||||||
|
const items = (knowledgeResponse.data ?? []).map((item) => normalizeKnowledgeItem(item))
|
||||||
|
knowledges.value = items
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categoryResponse = await getKnowledgeCategories()
|
||||||
|
categories.value = normalizeKnowledgeCategories(categoryResponse.data ?? [], items)
|
||||||
|
} catch (error) {
|
||||||
|
categories.value = normalizeKnowledgeCategories([], items)
|
||||||
|
ElMessage.warning(error instanceof Error ? error.message : '知识分类加载失败,已回退列表分类')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '知识库管理页面初始化失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editorMode.value = 'create'
|
||||||
|
activeKnowledge.value = null
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditDialog(knowledge: AdminKnowledgeListItem) {
|
||||||
|
detailLoadingId.value = knowledge.id
|
||||||
|
try {
|
||||||
|
const response = await getKnowledgeById(knowledge.id)
|
||||||
|
activeKnowledge.value = normalizeKnowledgeDetail(response.data)
|
||||||
|
editorMode.value = 'edit'
|
||||||
|
editorVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '知识详情读取失败')
|
||||||
|
} finally {
|
||||||
|
detailLoadingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(knowledge: AdminKnowledgeListItem, nextValue: boolean | string | number) {
|
||||||
|
const normalizedNextValue = Boolean(nextValue)
|
||||||
|
if (Boolean(knowledge.show) === normalizedNextValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLoadingMap.value[knowledge.id] = true
|
||||||
|
try {
|
||||||
|
await toggleKnowledgeVisibility(knowledge.id)
|
||||||
|
knowledge.show = normalizedNextValue
|
||||||
|
ElMessage.success('知识显示状态已更新')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '知识显示状态更新失败')
|
||||||
|
} finally {
|
||||||
|
toggleLoadingMap.value[knowledge.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(knowledge: AdminKnowledgeListItem) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除知识「${knowledge.title}」后无法恢复,确认继续吗?`, '删除知识', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteKnowledge(knowledge.id)
|
||||||
|
ElMessage.success('知识已删除')
|
||||||
|
await loadData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '知识删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSortEditor() {
|
||||||
|
sortDraft.value = knowledges.value.map((item) => ({ ...item }))
|
||||||
|
sortDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDraft(index: number, direction: -1 | 1) {
|
||||||
|
sortDraft.value = moveKnowledgeOrder(sortDraft.value, index, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSort() {
|
||||||
|
sortSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await sortKnowledges(sortDraft.value.map((item) => item.id))
|
||||||
|
ElMessage.success('知识排序已保存')
|
||||||
|
sortDialogVisible.value = false
|
||||||
|
await loadData()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '知识排序保存失败')
|
||||||
|
} finally {
|
||||||
|
sortSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([keyword, categoryFilter], () => {
|
||||||
|
current.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filteredKnowledges, (list) => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||||
|
if (current.value > maxPage) {
|
||||||
|
current.value = maxPage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(pageSize, () => {
|
||||||
|
current.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadData()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SystemPlaceholderView />
|
<div class="knowledge-page">
|
||||||
|
<section class="knowledge-hero">
|
||||||
|
<div class="knowledge-copy">
|
||||||
|
<h1>知识库管理</h1>
|
||||||
|
<p>在这里可以配置知识库,包括添加、删除、编辑、显隐与排序等操作。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-stats">
|
||||||
|
<article v-for="item in heroStats" :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-left">
|
||||||
|
<ElButton type="primary" @click="openCreateDialog">
|
||||||
|
<ElIcon><Plus /></ElIcon>
|
||||||
|
添加知识
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ElInput
|
||||||
|
v-model="keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索知识..."
|
||||||
|
class="toolbar-search"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ElIcon><Search /></ElIcon>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
|
||||||
|
<ElSelect
|
||||||
|
v-model="categoryFilter"
|
||||||
|
clearable
|
||||||
|
placeholder="分类"
|
||||||
|
class="toolbar-filter"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in categories"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<ElButton @click="openSortEditor">编辑排序</ElButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ElAlert
|
||||||
|
v-if="errorMessage"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
:title="errorMessage"
|
||||||
|
class="table-alert"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElButton size="small" @click="loadData">重新加载</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElAlert>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
:data="visibleKnowledges"
|
||||||
|
v-loading="loading"
|
||||||
|
class="knowledge-table"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="当前筛选条件下暂无知识"
|
||||||
|
>
|
||||||
|
<ElTableColumn prop="id" label="ID" width="88" />
|
||||||
|
<ElTableColumn label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElSwitch
|
||||||
|
:model-value="Boolean(row.show)"
|
||||||
|
:loading="isToggleLoading(row.id)"
|
||||||
|
@change="handleToggle(row, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="标题" min-width="560">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="title-cell">
|
||||||
|
<strong>{{ row.title }}</strong>
|
||||||
|
<small>最近更新 {{ formatDateTime(row.updated_at) }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="分类" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag effect="plain" round>{{ getKnowledgeCategoryLabel(row.category) }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-group">
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
class="action-btn"
|
||||||
|
:loading="isDetailLoading(row.id)"
|
||||||
|
@click="openEditDialog(row)"
|
||||||
|
>
|
||||||
|
<ElIcon><EditPen /></ElIcon>
|
||||||
|
</ElButton>
|
||||||
|
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
|
||||||
|
<ElIcon><Delete /></ElIcon>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<footer class="table-footer">
|
||||||
|
<span>已选择 0 项,共 {{ filteredKnowledges.length }} 项</span>
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="current"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="sizes, prev, pager, next"
|
||||||
|
:total="filteredKnowledges.length"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<KnowledgeEditorDialog
|
||||||
|
v-model:visible="editorVisible"
|
||||||
|
:mode="editorMode"
|
||||||
|
:knowledge="activeKnowledge"
|
||||||
|
:categories="categories"
|
||||||
|
@success="() => loadData()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElDialog
|
||||||
|
v-model="sortDialogVisible"
|
||||||
|
width="min(680px, calc(100vw - 32px))"
|
||||||
|
title="编辑排序"
|
||||||
|
class="sort-dialog"
|
||||||
|
>
|
||||||
|
<div class="sort-shell">
|
||||||
|
<p class="sort-copy">按照当前展示顺序调整知识条目排序,保存后会同步到后台 `/knowledge/sort`。</p>
|
||||||
|
|
||||||
|
<div class="sort-list">
|
||||||
|
<article
|
||||||
|
v-for="(item, index) in sortDraft"
|
||||||
|
:key="item.id"
|
||||||
|
class="sort-item"
|
||||||
|
>
|
||||||
|
<div class="sort-item__main">
|
||||||
|
<span class="sort-index">{{ index + 1 }}</span>
|
||||||
|
<div class="sort-meta">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span>{{ getKnowledgeCategoryLabel(item.category) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sort-actions">
|
||||||
|
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
|
||||||
|
<ElIcon><ArrowUp /></ElIcon>
|
||||||
|
上移
|
||||||
|
</ElButton>
|
||||||
|
<ElButton :disabled="index === sortDraft.length - 1" @click="moveDraft(index, 1)">
|
||||||
|
<ElIcon><ArrowDown /></ElIcon>
|
||||||
|
下移
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="sort-footer">
|
||||||
|
<ElButton @click="sortDialogVisible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="sortSubmitting" @click="submitSort">
|
||||||
|
保存排序
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss" src="./SystemKnowledgeView.scss"></style>
|
||||||
|
|||||||
@@ -502,6 +502,39 @@ class StatController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the comparison window used by traffic rank change calculation.
|
||||||
|
*
|
||||||
|
* Daily traffic statistics are stored with `record_at` pinned to the start
|
||||||
|
* of the day. For the dashboard `24h` preset, comparing by "same span in
|
||||||
|
* seconds" would shift the previous window to `00:00:01`, which skips the
|
||||||
|
* whole yesterday row and makes change percentages fall back to `0`.
|
||||||
|
*
|
||||||
|
* To keep the requested minimal scope, only the single-day preset is
|
||||||
|
* aligned to the exact previous calendar day; longer ranges keep the
|
||||||
|
* existing equal-span comparison behavior.
|
||||||
|
*
|
||||||
|
* @param int $startDate
|
||||||
|
* @param int $endDate
|
||||||
|
* @return array{start: int, end: int}
|
||||||
|
*/
|
||||||
|
protected function resolveTrafficRankComparisonWindow(int $startDate, int $endDate): array
|
||||||
|
{
|
||||||
|
$currentWindowDays = (int) floor(max(0, $endDate - $startDate) / 86400) + 1;
|
||||||
|
|
||||||
|
if ($currentWindowDays === 1) {
|
||||||
|
return [
|
||||||
|
'start' => $startDate - 86400,
|
||||||
|
'end' => $startDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'start' => $startDate - ($endDate - $startDate),
|
||||||
|
'end' => $startDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get traffic ranking data for nodes or users
|
* Get traffic ranking data for nodes or users
|
||||||
*
|
*
|
||||||
@@ -521,8 +554,9 @@ class StatController extends Controller
|
|||||||
$startDate = $request->input('start_time', strtotime('-7 days'));
|
$startDate = $request->input('start_time', strtotime('-7 days'));
|
||||||
$endDate = $request->input('end_time', time());
|
$endDate = $request->input('end_time', time());
|
||||||
$limit = (int) $request->input('limit', 10);
|
$limit = (int) $request->input('limit', 10);
|
||||||
$previousStartDate = $startDate - ($endDate - $startDate);
|
$comparisonWindow = $this->resolveTrafficRankComparisonWindow($startDate, $endDate);
|
||||||
$previousEndDate = $startDate;
|
$previousStartDate = $comparisonWindow['start'];
|
||||||
|
$previousEndDate = $comparisonWindow['end'];
|
||||||
|
|
||||||
if ($type === 'node') {
|
if ($type === 'node') {
|
||||||
// Get node traffic data
|
// Get node traffic data
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\V2\Admin\StatController;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionMethod;
|
||||||
|
|
||||||
|
class StatControllerTrafficRankWindowTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_single_day_rank_window_compares_with_exact_previous_day(): void
|
||||||
|
{
|
||||||
|
$startDate = strtotime('2026-04-24 00:00:00');
|
||||||
|
$endDate = strtotime('2026-04-24 23:59:59');
|
||||||
|
|
||||||
|
$comparisonWindow = $this->resolveWindow($startDate, $endDate);
|
||||||
|
|
||||||
|
$this->assertSame(strtotime('2026-04-23 00:00:00'), $comparisonWindow['start']);
|
||||||
|
$this->assertSame($startDate, $comparisonWindow['end']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_multi_day_rank_window_keeps_existing_equal_span_behavior(): void
|
||||||
|
{
|
||||||
|
$startDate = strtotime('2026-04-18 00:00:00');
|
||||||
|
$endDate = strtotime('2026-04-24 23:59:59');
|
||||||
|
|
||||||
|
$comparisonWindow = $this->resolveWindow($startDate, $endDate);
|
||||||
|
|
||||||
|
$this->assertSame($startDate - ($endDate - $startDate), $comparisonWindow['start']);
|
||||||
|
$this->assertSame($startDate, $comparisonWindow['end']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{start: int, end: int}
|
||||||
|
*/
|
||||||
|
private function resolveWindow(int $startDate, int $endDate): array
|
||||||
|
{
|
||||||
|
$controller = new StatController();
|
||||||
|
$method = new ReflectionMethod(StatController::class, 'resolveTrafficRankComparisonWindow');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var array{start: int, end: int} $result */
|
||||||
|
$result = $method->invoke($controller, $startDate, $endDate);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user