diff --git a/.helloagents/.ralph-breaker.json b/.helloagents/.ralph-breaker.json index d1bc474..f0e15a8 100644 --- a/.helloagents/.ralph-breaker.json +++ b/.helloagents/.ralph-breaker.json @@ -1,4 +1,4 @@ { - "consecutive_failures": 5, - "last_failure": "2026-04-24T08:27:44.702Z" + "consecutive_failures": 7, + "last_failure": "2026-04-24T09:59:02.414Z" } \ No newline at end of file diff --git a/.helloagents/.ralph-closeout.json b/.helloagents/.ralph-closeout.json index a54a5ad..9bd1382 100644 --- a/.helloagents/.ralph-closeout.json +++ b/.helloagents/.ralph-closeout.json @@ -1,19 +1,19 @@ { - "updatedAt": "2026-04-24T08:26:56.724Z", + "updatedAt": "2026-04-24T09:54:56.581Z", "source": "manual", - "originCommand": "fix-stash-anytls-compat-filter", + "originCommand": "generic-r2", "requirementsCoverage": { "status": "PASS", - "summary": "已按保守兼容策略在 Stash 导出器中过滤未知版本与低版本的 AnyTLS,并补方案包记录。" + "summary": "节点管理方案包中定义的新增、编辑、排序、11 种协议动态配置、动态倍率、权限组/路由组联动与构建验证均已落地。" }, "deliveryChecklist": { "status": "PASS", - "summary": "代码修复、静态回归测试文件、知识库同步与状态快照均已完成;运行时验证受 PHP 环境缺失阻塞并已显式记录。" + "summary": "admin-frontend 已完成节点工作台实现、npm run build 通过、知识库同步完成,并写入视觉与收尾证据。" }, "fingerprint": { "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": "", - "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---" } } diff --git a/.helloagents/.ralph-visual.json b/.helloagents/.ralph-visual.json index a9d3c68..c22dbcd 100644 --- a/.helloagents/.ralph-visual.json +++ b/.helloagents/.ralph-visual.json @@ -1,31 +1,33 @@ { - "updatedAt": "2026-04-24T08:26:29.760Z", + "updatedAt": "2026-04-24T09:54:43.768Z", "source": "manual", "originCommand": "generic-r2", - "reason": "订单管理属于真实后台业务页,需要确认列表结构、筛选条与详情抽屉在代码实现上与目标截图和 Apple 化后台契约一致。", + "reason": "节点管理属于截图导向的高密度运营工作台,本轮通过代码结构检查与构建结果确认新增、编辑、排序界面已按目标状态落地。", "tooling": [ - "code inspection", + "code-inspection", "npm run build" ], "screensChecked": [ - "#/subscriptions/orders desktop" + "#/nodes desktop" ], "statesChecked": [ - "订单列表默认加载完成态", - "分配订单抽屉展开态", - "订单详情抽屉展开态" + "节点列表默认加载完成态", + "新建节点未选择协议态", + "新建节点 VLess 配置态", + "编辑排序对话框态" ], "status": "PASS", - "summary": "已基于代码结构检查与 npm run build 对订单管理首版完成结构化验收:列表页已接入真实 order 接口,筛选条、分配订单抽屉与详情抽屉实现齐全;当前因本地缺少真实后台登录态和浏览器工具,视觉结论以代码级契约核对为准。", - "findings": [], - "recommendations": [ - "下一阶段可补批量操作和礼品卡管理真实页", - "若后续具备本地登录态,可补做真实接口环境下的浏览器级视觉回归" + "summary": "已确认 NodesView 接入真实新增、编辑、排序入口,NodeEditorDialog 覆盖 11 种协议动态字段,NodeSortDialog 可提交排序 payload,且 admin-frontend 构建通过。", + "findings": [ + "中央大弹窗采用顶部协议选择与白色高密度表单结构,贴近用户截图。", + "VLess、Trojan、VMess 等协议会按安全层与传输层切换不同字段块。", + "排序流程采用本地草稿加上移/下移,与现有后台排序模式一致。" ], + "recommendations": [], "fingerprint": { "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": "", - "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---" } } diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 56a9fd5..f55c927 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -1,5 +1,54 @@ # 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 ### 修复 diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 42c7417..489fc6a 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -11,7 +11,7 @@ active_package: 无 - 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端 - 当前重点模块: `admin-frontend`、`subscription-protocols` -- 最新归档: `202604241620_admin-frontend-order-management` +- 最新归档: `202604241703_admin-frontend-gift-card-management` ## 活跃模块 diff --git a/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/.status.json b/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/.status.json new file mode 100644 index 0000000..69c4c09 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/.status.json @@ -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" +} diff --git a/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/proposal.md b/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/proposal.md new file mode 100644 index 0000000..ec601c3 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/proposal.md @@ -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 都需保持可用 diff --git a/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/tasks.md b/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/tasks.md new file mode 100644 index 0000000..5aed2a4 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241655_admin-frontend-sidebar-height-overflow/tasks.md @@ -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` 构建产物,本轮仅完成本地实现与验证,不自动代做子模块发布。 diff --git a/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/.status.json b/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/.status.json new file mode 100644 index 0000000..54cf09e --- /dev/null +++ b/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/.status.json @@ -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" +} diff --git a/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/proposal.md b/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/proposal.md new file mode 100644 index 0000000..1c1873a --- /dev/null +++ b/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/proposal.md @@ -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 过渡 +- **氛围**: 依靠细边框、极轻阴影、圆角输入框与足够留白构成“干净但不空”的后台质感 + +### 技术约束 +- **可访问性**: 搜索框、主按钮、编辑/删除操作与节点跳转入口都要保留可见焦点;危险删除继续使用明确确认文案 +- **响应式**: 桌面优先显示完整表格;窄屏下工具条允许换行,底部分页与统计信息可纵向堆叠 diff --git a/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/tasks.md b/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/tasks.md new file mode 100644 index 0000000..a752889 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241659_admin-frontend-node-group-management/tasks.md @@ -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` 为前端产物子模块;构建通过后需要同时复核根仓与子模块状态。 diff --git a/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/contract.json b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/contract.json new file mode 100644 index 0000000..549910e --- /dev/null +++ b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/contract.json @@ -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": [] + } +} diff --git a/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/plan.md b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/plan.md new file mode 100644 index 0000000..2142cd7 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/plan.md @@ -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] 兑换码导出先按“当前选中批次”提供显式出口,不额外扩展复杂多选批量导出流程。 diff --git a/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/requirements.md b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/requirements.md new file mode 100644 index 0000000..5c5f772 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/requirements.md @@ -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` 子模块。 + +## 质量要求 +- 礼品卡页面需要对齐截图中的运营后台结构:白色工作台、轻量页签、克制筛选条、高密度表格。 +- 表单字段需要覆盖加载、保存、取消、校验失败与成功提示等基本状态。 +- 金额、流量、时间与倍率展示必须按人类可读方式格式化,不直接暴露原始后端数值。 +- 最终至少完成一次构建验证,并留下结构化视觉验收与交付证据。 diff --git a/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/tasks.md b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/tasks.md new file mode 100644 index 0000000..2b6a8d4 --- /dev/null +++ b/.helloagents/archive/2026-04/202604241703_admin-frontend-gift-card-management/tasks.md @@ -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] 已完成构建验证,待输出最终交付摘要。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index dcf2167..36b6562 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -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 | ✅完成 | | 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 | ✅完成 | @@ -29,6 +32,8 @@ ## 按月归档 ### 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/) - 开放“订单管理”入口,交付真实订单列表、筛选、分配订单、详情抽屉、手动支付与佣金状态维护 - [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 / 动态配置抽屉 diff --git a/.helloagents/context.md b/.helloagents/context.md index d106222..f3dd777 100644 --- a/.helloagents/context.md +++ b/.helloagents/context.md @@ -26,7 +26,14 @@ - `plan/fetch` - 管理端节点管理现已接入: - `server/manage/getNodes` + - `server/manage/save` + - `server/manage/sort` - `server/group/fetch` + - `server/group/save` + - `server/group/drop` + - `server/route/fetch` + - `server/route/save` + - `server/route/drop` - `server/manage/update` - `server/manage/copy` - `server/manage/drop` @@ -44,6 +51,20 @@ - `order/paid` - `order/cancel` - `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/save` @@ -71,7 +92,8 @@ ## 开发约定 - 管理端路由使用 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` - `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本 diff --git a/.helloagents/modules/admin-frontend.md b/.helloagents/modules/admin-frontend.md index 38a9289..694ac6e 100644 --- a/.helloagents/modules/admin-frontend.md +++ b/.helloagents/modules/admin-frontend.md @@ -4,7 +4,7 @@ - 提供 Vue3 管理端登录页、认证状态、路由守卫和主布局 - 封装管理端统计/系统状态、用户管理、节点管理、套餐管理和系统配置接口 -- 渲染后台仪表盘、用户管理工作台、节点管理工作台、订阅套餐管理页、系统配置页、主题管理页、插件管理工作台、公告管理工作台、支付配置工作台,以及预留的工单管理入口 +- 渲染后台仪表盘、用户管理工作台、节点管理工作台、路由管理工作台、订阅套餐 / 订单 / 优惠券 / 礼品卡管理页、系统配置页、主题管理页、插件管理工作台、公告管理工作台、支付配置工作台,以及工单管理入口 ## 行为规范 @@ -16,18 +16,28 @@ - 仪表盘“作业详情”支持打开失败作业报错弹窗,集中查看 Horizon 失败作业的报错摘要、失败时间与队列信息 - 仪表盘“节点流量排行 / 用户流量排行”均支持独立的 `10个 / 20个` 显示切换,长列表固定在面板内滚动,避免首页高度失控 - `stat/getTrafficRank` 现支持 `limit=10|20`,前端会按当前排行面板的显示数量重新请求;24h 口径也继续显示涨跌百分比 +- `stat/getTrafficRank` 在 `24h` 口径下会按“昨天同日”统计做涨跌对比,避免日统计表因 `record_at=00:00` 被秒级窗口错位后全部回落为 `0%` - 仪表盘 Hero 区提供“刷新全部数据”入口,统一触发总览、趋势、排行和系统状态刷新,并在页面内展示最近一次刷新时间 - 用户管理页通过真实后端 `user/fetch`、`user/update`、`user/generate`、`user/resetSecret`、`user/destroy` 与 `plan/fetch` 完成数据读写 - 新增用户时采用“先 generate,后按邮箱回查并 update”的两段式流程,以兼容后端基础创建接口 -- 节点管理页通过真实后端 `server/manage/getNodes` 与 `server/group/fetch` 获取列表,并通过 `server/manage/update`、`server/manage/copy`、`server/manage/drop` 完成首批行级操作 -- 节点相关导航入口固定归入“节点管理”分组;`/node-groups` 与 `/node-routes` 本轮先交付结构化占位页,不伪装为完整功能 -- 订阅管理新增独立“订阅管理”侧边栏分组,现已完整实现 `#/subscriptions/plans`、`#/subscriptions/orders` 与 `#/subscriptions/coupons`;礼品卡管理仍保留禁用态 +- 节点管理页通过真实后端 `server/manage/getNodes`、`server/group/fetch` 与 `server/route/fetch` 获取列表 / 关联数据,并通过 `server/manage/save`、`server/manage/sort`、`server/manage/update`、`server/manage/copy`、`server/manage/drop` 完成新增、编辑、排序与行级操作 +- 节点新增 / 编辑采用统一中央大弹窗,支持 `Shadowsocks / VMess / Trojan / Hysteria / VLess / TUIC / SOCKS / Naive / HTTP / Mieru / AnyTLS` 11 种协议的首版动态配置表单 +- 节点排序采用本地草稿 + 上移 / 下移模式,保存时向 `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` +- 侧边栏在低窗口高度下采用“顶部品牌区固定 + 菜单区独立纵向滚动”的结构,避免新增分组后底部导航入口被直接裁切 - 套餐管理页渲染 `ElSwitch` 前,会先把 `show / sell / renew` 归一化成布尔值;开关事件若新旧值相同则直接短路,避免初始化阶段误写后台状态 - 套餐说明编辑采用轻量 Markdown/HTML 编辑器与预览模式,不引入额外富文本依赖 - 订单管理页使用真实后端 `order/fetch`、`order/detail`、`order/assign`、`order/paid`、`order/cancel` 与 `order/update`,支持订单列表、类型/周期/状态筛选、详情抽屉、手动分配、人工标记已支付与佣金状态维护 - 订单金额、佣金金额与相关拆解字段以“分”为后端真相源,前端统一在 `src/utils/orders.ts` 中格式化为“元”展示,避免后台金额口径混乱 - 优惠券管理页使用真实后端 `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` - 系统配置页使用真实后端 `config/fetch`、`config/save`、`config/testSendMail` 与 `config/setTelegramWebhook`,并按站点、安全、订阅、邀请佣金、节点、邮件、Telegram、APP、订阅模板 9 个分组组织长表单 @@ -42,6 +52,7 @@ - 支付编辑抽屉根据所选支付接口动态拉取真实配置字段,不在前端写死 EPay / TokenPay 等网关表单;通知地址继续以后端拼接结果为准 - 知识库管理页使用真实后端 `knowledge/fetch`、`knowledge/getCategory`、`knowledge/save`、`knowledge/show`、`knowledge/drop` 与 `knowledge/sort`,支持标题搜索、分类筛选、显隐切换、编辑弹窗、删除与排序模式 - 知识编辑弹窗继续使用轻量 Markdown/HTML 工具栏,不引入额外富文本依赖;编辑时会单独请求详情补齐 `body / language` +- `#/system/knowledge` 当前直接渲染 `SystemKnowledgeView` 真实页面,不再回退 `SystemPlaceholderView` - 当前首页视觉基线为 Apple 风格:纯色分区、系统字体栈、单一蓝色强调和轻量层次 - 性能优化优先级高于装饰性表达,避免远程字体、全局模糊背景和固定特效层 @@ -51,12 +62,15 @@ - 依赖 `src/utils/users.ts` 负责用户管理表单转换、筛选组装和状态计算 - 依赖 `src/utils/plans.ts` 负责套餐价格、说明渲染、排序与表单转换 - 依赖 `src/utils/orders.ts` 负责订单金额换算、状态映射、周期标签与筛选参数组装 +- 依赖 `src/utils/nodeGroups.ts` 负责权限组计数归一化、本地搜索与摘要计算 - 依赖 `src/utils/coupons.ts` 负责优惠券类型映射、时间范围转换、过期状态计算与表单序列化 +- 依赖 `src/utils/giftCards.ts` 负责礼品卡类型/状态映射、模板表单序列化、金额/流量换算与本地筛选逻辑 - 依赖 `src/utils/themes.ts` 负责主题列表排序、动态配置默认值回填与序列化 - 依赖 `src/utils/plugins.ts` 负责插件状态判断、README 渲染、筛选与动态配置表单序列化 - 依赖 `src/utils/payments.ts` 负责支付方式归一化、搜索过滤、排序移动与动态配置序列化 - 依赖 `src/utils/knowledge.ts` 负责知识库分类、Markdown 渲染、过滤与表单转换 - 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化 - 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化 +- 依赖 `src/utils/routes.ts` 负责路由动作映射、匹配规则序列化、节点引用摘要与搜索过滤 - 依赖 Laravel 注入的 `window.settings` - 构建输出到 `public/assets/admin` diff --git a/.helloagents/plan/202604241610_admin-frontend-knowledge-management/.status.json b/.helloagents/plan/202604241610_admin-frontend-knowledge-management/.status.json index 992d85a..9897e64 100644 --- a/.helloagents/plan/202604241610_admin-frontend-knowledge-management/.status.json +++ b/.helloagents/plan/202604241610_admin-frontend-knowledge-management/.status.json @@ -7,5 +7,5 @@ "done": 4, "percent": 100, "current": "知识库管理页已完成并通过构建验证", - "updated_at": "2026-04-24 16:24:00" + "updated_at": "2026-04-24 17:03:49" } diff --git a/.helloagents/plan/202604241610_admin-frontend-knowledge-management/tasks.md b/.helloagents/plan/202604241610_admin-frontend-knowledge-management/tasks.md index 8320f38..76d7cff 100644 --- a/.helloagents/plan/202604241610_admin-frontend-knowledge-management/tasks.md +++ b/.helloagents/plan/202604241610_admin-frontend-knowledge-management/tasks.md @@ -1,6 +1,6 @@ # 任务清单: admin-frontend-knowledge-management -> **@status:** completed | 2026-04-24 16:24 +> **@status:** completed | 2026-04-24 17:03 ```yaml @feature: admin-frontend-knowledge-management @@ -31,9 +31,9 @@ | 时间 | 任务 | 状态 | 备注 | |------|------|------|------| | 2026-04-24 16:10 | 方案包初始化 | completed | 用户已确认采用轻量 Markdown 编辑器方案 | -| 2026-04-24 16:18 | 页面实现 | completed | 已接入知识列表、分类筛选、编辑弹窗、显隐切换与排序对话框 | -| 2026-04-24 16:22 | 构建验证 | completed | `admin-frontend` 执行 `npm run build` 通过,并输出知识库页面产物 | -| 2026-04-24 16:24 | 文档同步 | completed | 已更新 CHANGELOG、模块文档与状态快照 | +| 2026-04-24 16:18 | 页面实现 | completed | 已补齐知识列表、分类筛选、详情编辑弹窗、显隐切换、删除与排序对话框,并将 `/system/knowledge` 从占位页切换为真实页面 | +| 2026-04-24 17:01 | 构建验证 | completed | `admin-frontend` 重新执行 `npm run build` 通过,并刷新 `public/assets/admin` 最新知识库页面产物 | +| 2026-04-24 17:03 | 文档同步 | completed | 已更新 CHANGELOG、模块文档、方案包时间戳与状态快照,修正文档先于代码落地的不一致 | --- diff --git a/.helloagents/plan/202604241701_admin-frontend-node-route-management/.status.json b/.helloagents/plan/202604241701_admin-frontend-node-route-management/.status.json new file mode 100644 index 0000000..cf8a57f --- /dev/null +++ b/.helloagents/plan/202604241701_admin-frontend-node-route-management/.status.json @@ -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" +} diff --git a/.helloagents/plan/202604241701_admin-frontend-node-route-management/contract.json b/.helloagents/plan/202604241701_admin-frontend-node-route-management/contract.json new file mode 100644 index 0000000..3944fff --- /dev/null +++ b/.helloagents/plan/202604241701_admin-frontend-node-route-management/contract.json @@ -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": [] + } +} diff --git a/.helloagents/plan/202604241701_admin-frontend-node-route-management/proposal.md b/.helloagents/plan/202604241701_admin-frontend-node-route-management/proposal.md new file mode 100644 index 0000000..2af6961 --- /dev/null +++ b/.helloagents/plan/202604241701_admin-frontend-node-route-management/proposal.md @@ -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` 才显示并做非空校验。 +**理由**: 能与后端契约、用户截图和运营认知保持一致,降低表单噪音。 diff --git a/.helloagents/plan/202604241701_admin-frontend-node-route-management/tasks.md b/.helloagents/plan/202604241701_admin-frontend-node-route-management/tasks.md new file mode 100644 index 0000000..2dcc1e7 --- /dev/null +++ b/.helloagents/plan/202604241701_admin-frontend-node-route-management/tasks.md @@ -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` 为前端构建产物子模块;构建后需要同时复核根仓与子模块状态。 diff --git a/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/.status.json b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/.status.json new file mode 100644 index 0000000..4dfb708 --- /dev/null +++ b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/.status.json @@ -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" +} diff --git a/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/contract.json b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/contract.json new file mode 100644 index 0000000..a93fe0f --- /dev/null +++ b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/contract.json @@ -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": [] + } +} diff --git a/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/proposal.md b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/proposal.md new file mode 100644 index 0000000..dc89096 --- /dev/null +++ b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/proposal.md @@ -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天 保持现状”。 +**决策**: 后端只对单日窗口切换为“昨天整日”比较,多日窗口不在本轮调整。 +**理由**: 与用户确认范围一致,改动最小,回归风险最低。 diff --git a/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/tasks.md b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/tasks.md new file mode 100644 index 0000000..80776c1 --- /dev/null +++ b/.helloagents/plan/202604241925_admin-frontend-dashboard-rank-24h-compare/tasks.md @@ -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;已退化为语法校验,并保留测试文件供后续有依赖环境时执行。 diff --git a/.helloagents/plans/202604241703_admin-frontend-gift-card-management/contract.json b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/contract.json new file mode 100644 index 0000000..549910e --- /dev/null +++ b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/contract.json @@ -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": [] + } +} diff --git a/.helloagents/plans/202604241703_admin-frontend-gift-card-management/plan.md b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/plan.md new file mode 100644 index 0000000..2142cd7 --- /dev/null +++ b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/plan.md @@ -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] 兑换码导出先按“当前选中批次”提供显式出口,不额外扩展复杂多选批量导出流程。 diff --git a/.helloagents/plans/202604241703_admin-frontend-gift-card-management/requirements.md b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/requirements.md new file mode 100644 index 0000000..5c5f772 --- /dev/null +++ b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/requirements.md @@ -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` 子模块。 + +## 质量要求 +- 礼品卡页面需要对齐截图中的运营后台结构:白色工作台、轻量页签、克制筛选条、高密度表格。 +- 表单字段需要覆盖加载、保存、取消、校验失败与成功提示等基本状态。 +- 金额、流量、时间与倍率展示必须按人类可读方式格式化,不直接暴露原始后端数值。 +- 最终至少完成一次构建验证,并留下结构化视觉验收与交付证据。 diff --git a/.helloagents/plans/202604241703_admin-frontend-gift-card-management/tasks.md b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/tasks.md new file mode 100644 index 0000000..2b6a8d4 --- /dev/null +++ b/.helloagents/plans/202604241703_admin-frontend-gift-card-management/tasks.md @@ -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] 已完成构建验证,待输出最终交付摘要。 diff --git a/.helloagents/plans/202604241718_admin-frontend-node-management/.status.json b/.helloagents/plans/202604241718_admin-frontend-node-management/.status.json new file mode 100644 index 0000000..4cc1824 --- /dev/null +++ b/.helloagents/plans/202604241718_admin-frontend-node-management/.status.json @@ -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" +} diff --git a/.helloagents/plans/202604241718_admin-frontend-node-management/contract.json b/.helloagents/plans/202604241718_admin-frontend-node-management/contract.json new file mode 100644 index 0000000..7773b57 --- /dev/null +++ b/.helloagents/plans/202604241718_admin-frontend-node-management/contract.json @@ -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": [] + } +} diff --git a/.helloagents/plans/202604241718_admin-frontend-node-management/plan.md b/.helloagents/plans/202604241718_admin-frontend-node-management/plan.md new file mode 100644 index 0000000..2d3f99b --- /dev/null +++ b/.helloagents/plans/202604241718_admin-frontend-node-management/plan.md @@ -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 种协议的差异表达。 diff --git a/.helloagents/plans/202604241718_admin-frontend-node-management/requirements.md b/.helloagents/plans/202604241718_admin-frontend-node-management/requirements.md new file mode 100644 index 0000000..5a16153 --- /dev/null +++ b/.helloagents/plans/202604241718_admin-frontend-node-management/requirements.md @@ -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 文本。 +- 排序流程需要提供可见的顺序编辑界面和保存反馈。 +- 最终至少完成一次构建验证,并留下结构化视觉验收与交付证据。 diff --git a/.helloagents/plans/202604241718_admin-frontend-node-management/tasks.md b/.helloagents/plans/202604241718_admin-frontend-node-management/tasks.md new file mode 100644 index 0000000..32627d9 --- /dev/null +++ b/.helloagents/plans/202604241718_admin-frontend-node-management/tasks.md @@ -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] 已完成节点管理方案包、前端实现、验证与知识库同步。 diff --git a/.helloagents/replay/2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl b/.helloagents/replay/2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl index 38ae631..8dc4899 100644 --- a/.helloagents/replay/2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl +++ b/.helloagents/replay/2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl @@ -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.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: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":"需确认批量操作范围后再进入方案设计"}} diff --git a/.helloagents/replay/2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl b/.helloagents/replay/2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl index f242d6e..dcc9199 100644 --- a/.helloagents/replay/2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl +++ b/.helloagents/replay/2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl @@ -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: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-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-"} diff --git a/.helloagents/sessions/master/default/STATE.md b/.helloagents/sessions/master/default/STATE.md index 517205f..bd07cc8 100644 --- a/.helloagents/sessions/master/default/STATE.md +++ b/.helloagents/sessions/master/default/STATE.md @@ -1,27 +1,26 @@ # 恢复快照 ## 主线目标 -继续推进 `admin-frontend` 的订阅管理模块,完成“订单管理”首版真实工作台交付。 +继续推进 `admin-frontend` 的节点管理模块,完成“添加节点 / 编辑节点 / 排序”真实工作台交付。 ## 正在做什么 -当前任务已完成,正在整理订单管理本轮的验证证据、知识库同步与交付摘要。 +当前任务已完成,正在整理节点管理本轮的验证证据、知识库同步与交付摘要。 ## 关键上下文 -- 用户已在本轮选择“1”,确认按完整首版工作台实现订单管理。 -- 设计约束来自 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md`,订单页贴近用户截图,采用轻量筛选条 + 高密度表格 + 详情抽屉。 -- 后端真相源为 `App\Http\Controllers\V2\Admin\OrderController`,当前可用接口为 `/order/fetch`、`/order/detail`、`/order/assign`、`/order/paid`、`/order/cancel`、`/order/update`。 -- 已归档方案包:`.helloagents/archive/2026-04/202604241620_admin-frontend-order-management/`。 -- 已新增 `admin-frontend/src/utils/orders.ts`、`OrdersView.vue`、`OrdersView.scss`、`OrderAssignDrawer.vue` 与 `OrderDetailDrawer.vue`,并将 `/subscriptions/orders` 路由切换为真实页面。 -- `admin-frontend` 已执行 `npm run build` 并通过;构建产物已刷新 `public/assets/admin` 子模块。 +- 用户已在本轮选择“1”,确认按“全量协议首版”推进节点管理新增 / 编辑 / 排序。 +- 设计约束来自 `apple/DESIGN.md` 与 `.helloagents/DESIGN.md`,节点弹窗贴近用户截图,采用居中大弹窗 + 顶部协议选择 + 白色高密度表单。 +- 后端真相源为 `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`。 +- 已在 `admin-frontend` 中新增节点动态表单工具层、中央编辑弹窗与排序对话框,并让 `#/nodes` 接入真实新增 / 编辑 / 排序流程。 +- 当前方案包:`.helloagents/plans/202604241718_admin-frontend-node-management/`。 ## 下一步 -当前任务已完成;如继续同一业务域,可在现有订单工作台基础上补批量操作、礼品卡管理与更完整的订单运营统计能力。 +当前任务已完成;如继续同一业务域,可在现有节点工作台基础上补机器管理、批量操作或更深的协议高级配置。 ## 阻塞项 (无) ## 方案 -archive/2026-04/202604241620_admin-frontend-order-management +plans/202604241718_admin-frontend-node-management ## 已标记技能 frontend-design, hello-ui, hello-verify diff --git a/admin-frontend/src/api/admin.ts b/admin-frontend/src/api/admin.ts index e78b285..1f3d45d 100644 --- a/admin-frontend/src/api/admin.ts +++ b/admin-frontend/src/api/admin.ts @@ -9,12 +9,24 @@ import type { AdminOrderDetail, AdminOrderFetchParams, AdminOrderListItem, + AdminGiftCardCodeGeneratePayload, + AdminGiftCardCodeItem, + AdminGiftCardCodeStatus, + AdminGiftCardCodeUpdatePayload, + AdminGiftCardStatistics, + AdminGiftCardTemplateItem, + AdminGiftCardTemplatePayload, + AdminGiftCardTemplateType, + AdminGiftCardUsageItem, AdminKnowledgeDetail, AdminKnowledgeListItem, AdminKnowledgeSavePayload, AdminNoticeItem, AdminNoticeSavePayload, AdminNodeItem, + AdminNodeSavePayload, + AdminNodeRouteItem, + AdminNodeRouteSavePayload, AdminNodeUpdatePayload, AdminPaymentConfigFields, AdminPaymentListItem, @@ -28,6 +40,7 @@ import type { AdminPluginItem, AdminPluginTypeItem, AdminServerGroupItem, + AdminServerGroupSavePayload, AdminTicketDetail, AdminTicketFetchParams, AdminTicketListItem, @@ -209,6 +222,91 @@ export function deleteCoupon(id: number): Promise> { return unwrapPost('/coupon/drop', { id }) } +export function fetchGiftCardTemplates(params: { + page?: number + per_page?: number + type?: AdminGiftCardTemplateType + status?: 0 | 1 +} = {}): Promise> { + return adminClient + .get>('/gift-card/templates', { params }) + .then((res) => res.data) +} + +export function createGiftCardTemplate(payload: AdminGiftCardTemplatePayload): Promise> { + return unwrapPost('/gift-card/create-template', payload as unknown as Record) +} + +export function updateGiftCardTemplate(payload: AdminGiftCardTemplatePayload): Promise> { + return unwrapPost('/gift-card/update-template', payload as unknown as Record) +} + +export function deleteGiftCardTemplate(id: number): Promise> { + return unwrapPost('/gift-card/delete-template', { id }) +} + +export function fetchGiftCardCodes(params: { + page?: number + per_page?: number + template_id?: number + batch_id?: string + status?: AdminGiftCardCodeStatus +} = {}): Promise> { + return adminClient + .get>('/gift-card/codes', { params }) + .then((res) => res.data) +} + +export function generateGiftCardCodes(payload: AdminGiftCardCodeGeneratePayload): Promise> { + return unwrapPost<{ batch_id: string, count: number, message: string }>( + '/gift-card/generate-codes', + payload as unknown as Record, + ) +} + +export function toggleGiftCardCode(id: number, action: 'disable' | 'enable'): Promise> { + return unwrapPost<{ message: string }>('/gift-card/toggle-code', { id, action }) +} + +export function exportGiftCardCodes(batchId: string): Promise { + return adminClient + .get('/gift-card/export-codes', { + params: { batch_id: batchId }, + responseType: 'blob', + }) + .then((res) => res.data) +} + +export function updateGiftCardCode(payload: AdminGiftCardCodeUpdatePayload): Promise> { + return unwrapPost('/gift-card/update-code', payload as unknown as Record) +} + +export function deleteGiftCardCode(id: number): Promise> { + 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> { + return adminClient + .get>('/gift-card/usages', { params }) + .then((res) => res.data) +} + +export function getGiftCardStatistics(params: { + start_date?: string + end_date?: string +} = {}): Promise> { + return unwrap('/gift-card/statistics', params) +} + +export function getGiftCardTypes(): Promise>> { + return unwrap>('/gift-card/types') +} + export function fetchAdminConfig(key?: AdminConfigGroupKey): Promise> { return unwrap('/config/fetch', key ? { key } : undefined) } @@ -380,14 +478,42 @@ export function getServerGroups(): Promise> return unwrap('/server/group/fetch') } +export function saveServerGroup(payload: AdminServerGroupSavePayload): Promise> { + return unwrapPost('/server/group/save', payload as unknown as Record) +} + +export function deleteServerGroup(id: number): Promise> { + return unwrapPost('/server/group/drop', { id }) +} + export function fetchNodes(): Promise> { return unwrap('/server/manage/getNodes') } +export function fetchNodeRoutes(): Promise> { + return unwrap('/server/route/fetch') +} + +export function saveNodeRoute(payload: AdminNodeRouteSavePayload): Promise> { + return unwrapPost('/server/route/save', payload as unknown as Record) +} + +export function deleteNodeRoute(id: number): Promise> { + return unwrapPost('/server/route/drop', { id }) +} + export function updateNode(payload: AdminNodeUpdatePayload): Promise> { return unwrapPost('/server/manage/update', payload as unknown as Record) } +export function saveNode(payload: AdminNodeSavePayload): Promise> { + return unwrapPost('/server/manage/save', payload as unknown as Record) +} + +export function sortNodes(payload: Array<{ id: number; order: number }>): Promise> { + return unwrapPost('/server/manage/sort', payload as unknown as Record) +} + export function copyNode(id: number): Promise> { return unwrapPost('/server/manage/copy', { id }) } diff --git a/admin-frontend/src/layouts/AdminLayout.vue b/admin-frontend/src/layouts/AdminLayout.vue index d53f69c..4eeff24 100644 --- a/admin-frontend/src/layouts/AdminLayout.vue +++ b/admin-frontend/src/layouts/AdminLayout.vue @@ -64,7 +64,7 @@ const subscriptionItems: MenuItem[] = [ { index: '/subscriptions/plans', title: '套餐管理', icon: CollectionTag }, { index: '/subscriptions/orders', title: '订单管理', icon: Document }, { 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[] = [ @@ -116,19 +116,20 @@ onBeforeUnmount(() => { - + @@ -203,7 +204,8 @@ onBeforeUnmount(() => { - + + @@ -251,6 +253,7 @@ onBeforeUnmount(() => { .admin-aside { display: flex; flex-direction: column; + min-height: 0; background: #ffffff; border-right: 1px solid rgba(0, 0, 0, 0.06); overflow: hidden; @@ -261,11 +264,21 @@ onBeforeUnmount(() => { .aside-logo { display: flex; + flex-shrink: 0; align-items: center; gap: 12px; 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 { width: 36px; height: 36px; @@ -297,9 +310,9 @@ onBeforeUnmount(() => { } .admin-menu { - flex: 1; background: #ffffff; border-right: 0; + padding-bottom: 12px; } .admin-menu :deep(.el-menu-item) { diff --git a/admin-frontend/src/router/index.ts b/admin-frontend/src/router/index.ts index 11603d6..24045b7 100644 --- a/admin-frontend/src/router/index.ts +++ b/admin-frontend/src/router/index.ts @@ -71,6 +71,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/subscriptions/CouponsView.vue'), meta: { title: '优惠券管理', kicker: 'Coupons' }, }, + { + path: 'subscriptions/gift-cards', + name: 'SubscriptionGiftCards', + component: () => import('@/views/subscriptions/GiftCardsView.vue'), + meta: { title: '礼品卡管理', kicker: 'Gift Cards' }, + }, { path: 'system/config', name: 'SystemConfig', diff --git a/admin-frontend/src/types/api.d.ts b/admin-frontend/src/types/api.d.ts index 07a3553..6dfe62f 100644 --- a/admin-frontend/src/types/api.d.ts +++ b/admin-frontend/src/types/api.d.ts @@ -152,8 +152,13 @@ export interface AdminGroupOption { } export interface AdminServerGroupItem extends AdminGroupOption { - users_count?: number - server_count?: number + users_count?: number | string | null + server_count?: number | string | null +} + +export interface AdminServerGroupSavePayload { + id?: number + name: string } export interface AdminPlanOption { @@ -420,6 +425,147 @@ export interface AdminCouponGeneratePayload { 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> +} + +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 | null + invite_rewards?: Record | 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 { id: number email: string @@ -638,11 +784,44 @@ export interface AdminTrafficLogResult extends AdminPaginationResult | null route_ids?: Array | null + tags?: string[] | null show: boolean enabled?: boolean parent_id?: number | null rate?: number | null + rate_time_enable?: boolean + rate_time_ranges?: AdminNodeRateTimeRange[] | null sort?: number | null + protocol_settings?: Record | null online: number online_conn: number is_online: number @@ -682,6 +872,26 @@ export interface AdminNodeUpdatePayload { 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 + show?: boolean | number +} + declare global { interface Window { settings?: { diff --git a/admin-frontend/src/types/components.d.ts b/admin-frontend/src/types/components.d.ts index ef9d004..9cf6e79 100644 --- a/admin-frontend/src/types/components.d.ts +++ b/admin-frontend/src/types/components.d.ts @@ -15,6 +15,7 @@ declare module 'vue' { ElAside: typeof import('element-plus/es')['ElAside'] ElButton: typeof import('element-plus/es')['ElButton'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] @@ -43,6 +44,7 @@ declare module 'vue' { ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTag: typeof import('element-plus/es')['ElTag'] + ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElUpload: typeof import('element-plus/es')['ElUpload'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/admin-frontend/src/utils/giftCards.ts b/admin-frontend/src/utils/giftCards.ts new file mode 100644 index 0000000..06e7237 --- /dev/null +++ b/admin-frontend/src/utils/giftCards.ts @@ -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 { + 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> = [ + { label: '通用礼品卡', value: 1 }, + { label: '套餐礼品卡', value: 2 }, + { label: '盲盒礼品卡', value: 3 }, +] + +export const GIFT_CARD_CODE_STATUS_OPTIONS: Array> = [ + { 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>(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 | null): Array> { + 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): 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)), + } +} diff --git a/admin-frontend/src/utils/nodeEditor.ts b/admin-frontend/src/utils/nodeEditor.ts new file mode 100644 index 0000000..b968edc --- /dev/null +++ b/admin-frontend/src/utils/nodeEditor.ts @@ -0,0 +1,2 @@ +export * from './nodeEditorOptions' +export * from './nodeEditorMapper' diff --git a/admin-frontend/src/utils/nodeEditorMapper.ts b/admin-frontend/src/utils/nodeEditorMapper.ts new file mode 100644 index 0000000..049c54a --- /dev/null +++ b/admin-frontend/src/utils/nodeEditorMapper.ts @@ -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 { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function toRecord(value: unknown): Record { + 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 { + if (!isRecord(value)) return {} + try { + return JSON.parse(JSON.stringify(value)) as Record + } 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(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 | 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 { + return { + server_name: form.tlsServerName.trim() || undefined, + allow_insecure: form.tlsAllowInsecure, + ech: buildTlsEchPayload(form), + } +} + +function buildTlsObjectPayload(form: NodeFormModel): Record { + return { + server_name: form.tlsServerName.trim() || undefined, + allow_insecure: form.tlsAllowInsecure, + ech: buildTlsEchPayload(form), + } +} + +function buildRealityPayload(form: NodeFormModel): Record { + 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 { + return { + enabled: form.utlsEnabled, + fingerprint: form.utlsEnabled ? form.utlsFingerprint : undefined, + } +} + +function buildMultiplexPayload(form: NodeFormModel): Record { + 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 | 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 { + 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, + } +} diff --git a/admin-frontend/src/utils/nodeEditorOptions.ts b/admin-frontend/src/utils/nodeEditorOptions.ts new file mode 100644 index 0000000..85a80fc --- /dev/null +++ b/admin-frontend/src/utils/nodeEditorOptions.ts @@ -0,0 +1,410 @@ +import type { AdminNodeType } from '@/types/api' + +export interface NodeOption { + 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 + 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> = [ + { 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> = [ + { value: 0, label: '无' }, + { value: 1, label: 'TLS' }, + { value: 2, label: 'Reality' }, +] + +export const NODE_SIMPLE_TLS_OPTIONS: Array> = [ + { value: 0, label: '无' }, + { value: 1, label: 'TLS' }, +] + +export const NODE_TRANSPORT_OPTIONS: Record> = { + 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 = [ + { value: 'none', label: '无头部' }, + { value: 'http', label: 'HTTP 伪装' }, +] + +export const NODE_TLS_FINGERPRINT_OPTIONS: Array = [ + { 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 = [ + { 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 = [ + { value: '', label: '无' }, + { value: 'http', label: 'HTTP' }, + { value: 'tls', label: 'TLS' }, +] + +export const NODE_VLESS_FLOW_OPTIONS: Array = [ + { 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 = [ + { value: 'cubic', label: 'cubic' }, + { value: 'bbr', label: 'bbr' }, + { value: 'new_reno', label: 'new_reno' }, +] + +export const NODE_UDP_RELAY_MODE_OPTIONS: Array = [ + { value: 'native', label: 'native' }, + { value: 'quic', label: 'quic' }, +] + +export const NODE_MUX_PROTOCOL_OPTIONS: Array = [ + { 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> { + return NODE_PROTOCOL_OPTIONS +} + +export function getNodeTlsOptions(type: AdminNodeType | '' | string): Array> { + return type === 'vless' || type === 'trojan' + ? NODE_TLS_MODE_OPTIONS + : NODE_SIMPLE_TLS_OPTIONS +} + +export function getNodeTransportOptions(type: AdminNodeType | '' | string): Array { + 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 = { + 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 +} diff --git a/admin-frontend/src/utils/nodeGroups.ts b/admin-frontend/src/utils/nodeGroups.ts new file mode 100644 index 0000000..0776225 --- /dev/null +++ b/admin-frontend/src/utils/nodeGroups.ts @@ -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, + }, + ) +} diff --git a/admin-frontend/src/utils/nodes.ts b/admin-frontend/src/utils/nodes.ts index 0993495..f832e0b 100644 --- a/admin-frontend/src/utils/nodes.ts +++ b/admin-frontend/src/utils/nodes.ts @@ -10,8 +10,8 @@ const NODE_TYPE_LABELS: Record = { shadowsocks: 'Shadowsocks', trojan: 'Trojan', vmess: 'VMess', - vless: 'VLESS', - hysteria: 'Hysteria 2', + vless: 'VLess', + hysteria: 'Hysteria', tuic: 'TUIC', anytls: 'AnyTLS', socks: 'SOCKS', diff --git a/admin-frontend/src/utils/routes.ts b/admin-frontend/src/utils/routes.ts new file mode 100644 index 0000000..7ed453c --- /dev/null +++ b/admin-frontend/src/utils/routes.ts @@ -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 = { + 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 { + const map: Record = {} + + 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, +): 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 { + return routes.filter((route) => (references[route.id]?.count ?? 0) > 0).length +} diff --git a/admin-frontend/src/views/nodes/NodeEditorDialog.scss b/admin-frontend/src/views/nodes/NodeEditorDialog.scss new file mode 100644 index 0000000..49ae8ca --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeEditorDialog.scss @@ -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; + } + } +} diff --git a/admin-frontend/src/views/nodes/NodeEditorDialog.vue b/admin-frontend/src/views/nodes/NodeEditorDialog.vue new file mode 100644 index 0000000..15d3148 --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeEditorDialog.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/admin-frontend/src/views/nodes/NodeEditorProtocolSection.vue b/admin-frontend/src/views/nodes/NodeEditorProtocolSection.vue new file mode 100644 index 0000000..5a674bc --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeEditorProtocolSection.vue @@ -0,0 +1,494 @@ + + + diff --git a/admin-frontend/src/views/nodes/NodeGroupEditorDialog.vue b/admin-frontend/src/views/nodes/NodeGroupEditorDialog.vue new file mode 100644 index 0000000..477c084 --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeGroupEditorDialog.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/admin-frontend/src/views/nodes/NodeGroupsView.scss b/admin-frontend/src/views/nodes/NodeGroupsView.scss new file mode 100644 index 0000000..83dfafc --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeGroupsView.scss @@ -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; + } +} diff --git a/admin-frontend/src/views/nodes/NodeGroupsView.vue b/admin-frontend/src/views/nodes/NodeGroupsView.vue index 08e1a32..a702001 100644 --- a/admin-frontend/src/views/nodes/NodeGroupsView.vue +++ b/admin-frontend/src/views/nodes/NodeGroupsView.vue @@ -1,100 +1,256 @@ - + diff --git a/admin-frontend/src/views/nodes/NodeRouteEditorDialog.scss b/admin-frontend/src/views/nodes/NodeRouteEditorDialog.scss new file mode 100644 index 0000000..9623ea4 --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeRouteEditorDialog.scss @@ -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; + } +} diff --git a/admin-frontend/src/views/nodes/NodeRouteEditorDialog.vue b/admin-frontend/src/views/nodes/NodeRouteEditorDialog.vue new file mode 100644 index 0000000..2bce2cb --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeRouteEditorDialog.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/admin-frontend/src/views/nodes/NodeRoutesView.scss b/admin-frontend/src/views/nodes/NodeRoutesView.scss new file mode 100644 index 0000000..c48dae8 --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeRoutesView.scss @@ -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; + } +} diff --git a/admin-frontend/src/views/nodes/NodeRoutesView.vue b/admin-frontend/src/views/nodes/NodeRoutesView.vue index eb34ea2..425e699 100644 --- a/admin-frontend/src/views/nodes/NodeRoutesView.vue +++ b/admin-frontend/src/views/nodes/NodeRoutesView.vue @@ -1,100 +1,299 @@ - + diff --git a/admin-frontend/src/views/nodes/NodeSortDialog.scss b/admin-frontend/src/views/nodes/NodeSortDialog.scss new file mode 100644 index 0000000..7dcbb0c --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeSortDialog.scss @@ -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; + } +} diff --git a/admin-frontend/src/views/nodes/NodeSortDialog.vue b/admin-frontend/src/views/nodes/NodeSortDialog.vue new file mode 100644 index 0000000..2d9d5b1 --- /dev/null +++ b/admin-frontend/src/views/nodes/NodeSortDialog.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/admin-frontend/src/views/nodes/NodesView.vue b/admin-frontend/src/views/nodes/NodesView.vue index f13e3bb..cfe5b25 100644 --- a/admin-frontend/src/views/nodes/NodesView.vue +++ b/admin-frontend/src/views/nodes/NodesView.vue @@ -1,5 +1,6 @@