feat(admin-frontend): 补齐用户节点与订单运营工作台

新增用户高级筛选、批量操作与更多行级动作,支持邮件、
CSV、封禁恢复、订单分配、邀请查看、流量记录与重置流量

增强节点管理页的分页、父子筛选、跨页勾选、批量修改与
单节点置顶,并补齐后端批量更新 host、group_ids、rate

修复订单佣金状态误判问题,新增真实佣金筛选与行级确认,
同时优化仪表盘排行悬浮详情展示

补充 admin-frontend 独立 Dockerfile、Caddy 配置与 GHCR
发布工作流,支持通过独立镜像部署管理前端
This commit is contained in:
yinjianm
2026-04-24 23:15:48 +08:00
parent e393b11b61
commit d4168720ac
65 changed files with 4114 additions and 438 deletions
@@ -0,0 +1,88 @@
name: Admin Frontend Docker Build and Publish
on:
push:
branches: ["master", "new-dev"]
paths:
- "admin-frontend/**"
- ".github/workflows/admin-frontend-docker-publish.yml"
workflow_dispatch:
concurrency:
group: admin-frontend-docker-publish-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/xboard-admin-frontend
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-tags: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
image=moby/buildkit:v0.20.0
network=host
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: echo "version=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,format=short,prefix=,enable=true
type=raw,value=new,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=${{ steps.get_version.outputs.version }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
- name: Build and push
id: build-and-push
uses: docker/build-push-action@v5
with:
context: ./admin-frontend
file: ./admin-frontend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=admin-frontend-docker-publish-${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=admin-frontend-docker-publish-${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_MULTI_PLATFORM=1
provenance: false
+2 -2
View File
@@ -1,4 +1,4 @@
{
"consecutive_failures": 7,
"last_failure": "2026-04-24T09:59:02.414Z"
"consecutive_failures": 11,
"last_failure": "2026-04-24T15:13:24.613Z"
}
+5 -5
View File
@@ -1,19 +1,19 @@
{
"updatedAt": "2026-04-24T09:54:56.581Z",
"updatedAt": "2026-04-24T15:11:19.543Z",
"source": "manual",
"originCommand": "generic-r2",
"requirementsCoverage": {
"status": "PASS",
"summary": "节点管理方案包中定义的新增、编辑、排序、11 种协议动态配置、动态倍率、权限组/路由组联动与构建验证均已落地。"
"summary": "已覆盖分页、父子筛选、单节点置顶、仅已勾选节点批量修改,以及 host/group_ids/rate 三项批量更新边界。"
},
"deliveryChecklist": {
"status": "PASS",
"summary": "admin-frontend 已完成节点工作台实现、npm run build 通过、知识库同步完成,并写入视觉与收尾证据。"
"summary": "admin-frontend 构建通过,节点页与后端批量修改链路已落地,知识库、归档索引、会话状态与交付证据已同步。"
},
"fingerprint": {
"available": true,
"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(-)",
"unstaged": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 32 +-\n .helloagents/CHANGELOG.md | 39 +-\n .helloagents/archive/_index.md | 4 +\n .helloagents/context.md | 12 +-\n .helloagents/modules/admin-frontend.md | 19 +-\n .../.status.json | 10 +-\n .../contract.json | 27 +-\n .../proposal.md | 19 +-\n .../tasks.md | 14 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 5 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 3 +\n .helloagents/sessions/master/default/STATE.md | 18 +-\n admin-frontend/src/api/admin.ts | 31 ++\n admin-frontend/src/types/api.d.ts | 28 ++\n admin-frontend/src/types/components.d.ts | 1 +\n admin-frontend/src/utils/nodes.ts | 12 +\n admin-frontend/src/utils/orders.ts | 50 ++-\n admin-frontend/src/utils/users.ts | 352 ++++++++++++++-\n .../src/views/dashboard/DashboardView.vue | 162 +++++--\n admin-frontend/src/views/nodes/NodesView.vue | 260 ++++++++++-\n .../src/views/subscriptions/OrderAssignDrawer.vue | 3 +-\n .../src/views/subscriptions/OrderDetailDrawer.vue | 20 +-\n .../src/views/subscriptions/OrdersView.scss | 16 +\n .../src/views/subscriptions/OrdersView.vue | 225 +++++++++-\n admin-frontend/src/views/users/UsersView.vue | 490 ++++++++-------------\n admin-frontend/vite.config.ts | 3 +-\n .../V2/Admin/Server/ManageController.php | 13 +\n app/Http/Controllers/V2/Admin/UserController.php | 5 +-\n public/assets/admin | 0\n 31 files changed, 1442 insertions(+), 447 deletions(-)",
"staged": "",
"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---"
"combined": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 32 +-\n .helloagents/CHANGELOG.md | 39 +-\n .helloagents/archive/_index.md | 4 +\n .helloagents/context.md | 12 +-\n .helloagents/modules/admin-frontend.md | 19 +-\n .../.status.json | 10 +-\n .../contract.json | 27 +-\n .../proposal.md | 19 +-\n .../tasks.md | 14 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 5 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 3 +\n .helloagents/sessions/master/default/STATE.md | 18 +-\n admin-frontend/src/api/admin.ts | 31 ++\n admin-frontend/src/types/api.d.ts | 28 ++\n admin-frontend/src/types/components.d.ts | 1 +\n admin-frontend/src/utils/nodes.ts | 12 +\n admin-frontend/src/utils/orders.ts | 50 ++-\n admin-frontend/src/utils/users.ts | 352 ++++++++++++++-\n .../src/views/dashboard/DashboardView.vue | 162 +++++--\n admin-frontend/src/views/nodes/NodesView.vue | 260 ++++++++++-\n .../src/views/subscriptions/OrderAssignDrawer.vue | 3 +-\n .../src/views/subscriptions/OrderDetailDrawer.vue | 20 +-\n .../src/views/subscriptions/OrdersView.scss | 16 +\n .../src/views/subscriptions/OrdersView.vue | 225 +++++++++-\n admin-frontend/src/views/users/UsersView.vue | 490 ++++++++-------------\n admin-frontend/vite.config.ts | 3 +-\n .../V2/Admin/Server/ManageController.php | 13 +\n app/Http/Controllers/V2/Admin/UserController.php | 5 +-\n public/assets/admin | 0\n 31 files changed, 1442 insertions(+), 447 deletions(-)\n---"
}
}
+12 -14
View File
@@ -1,10 +1,10 @@
{
"updatedAt": "2026-04-24T09:54:43.768Z",
"updatedAt": "2026-04-24T15:11:19.540Z",
"source": "manual",
"originCommand": "generic-r2",
"reason": "节点管理属于截图导向的高密度运营工作台,本轮通过代码结构检查与构建结果确认新增、编辑、排序界面已按目标状态落地。",
"reason": "节点管理本轮新增分页、父子筛选、已勾选批量修改与置顶动作,需要确认工作台节奏和批量作用域提示与 Apple 化后台契约一致。",
"tooling": [
"code-inspection",
"code inspection",
"npm run build"
],
"screensChecked": [
@@ -12,22 +12,20 @@
],
"statesChecked": [
"节点列表默认加载完成态",
"新建节点未选择协议态",
"新建节点 VLess 配置态",
"编辑排序对话框态"
"节点列表已勾选批量操作可用态",
"节点批量修改弹窗展开态",
"节点父子筛选切换态"
],
"status": "PASS",
"summary": "已确认 NodesView 接入真实新增、编辑、排序入口,NodeEditorDialog 覆盖 11 种协议动态字段,NodeSortDialog 可提交排序 payload,且 admin-frontend 构建通过。",
"findings": [
"中央大弹窗采用顶部协议选择与白色高密度表单结构,贴近用户截图。",
"VLess、Trojan、VMess 等协议会按安全层与传输层切换不同字段块。",
"排序流程采用本地草稿加上移/下移,与现有后台排序模式一致。"
"summary": "已通过代码级视觉验收确认节点页维持黑色 Hero + 白色工作台结构,工具条新增父/子节点筛选、批量修改和分页后仍保持高密度但可读的 Apple 化运营节奏。",
"findings": [],
"recommendations": [
"建议在真实登录态下再手动确认跨分页勾选与批量修改对后端返回数据的联动表现。"
],
"recommendations": [],
"fingerprint": {
"available": true,
"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(-)",
"unstaged": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 32 +-\n .helloagents/CHANGELOG.md | 39 +-\n .helloagents/archive/_index.md | 4 +\n .helloagents/context.md | 12 +-\n .helloagents/modules/admin-frontend.md | 19 +-\n .../.status.json | 10 +-\n .../contract.json | 27 +-\n .../proposal.md | 19 +-\n .../tasks.md | 14 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 5 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 3 +\n .helloagents/sessions/master/default/STATE.md | 18 +-\n admin-frontend/src/api/admin.ts | 31 ++\n admin-frontend/src/types/api.d.ts | 28 ++\n admin-frontend/src/types/components.d.ts | 1 +\n admin-frontend/src/utils/nodes.ts | 12 +\n admin-frontend/src/utils/orders.ts | 50 ++-\n admin-frontend/src/utils/users.ts | 352 ++++++++++++++-\n .../src/views/dashboard/DashboardView.vue | 162 +++++--\n admin-frontend/src/views/nodes/NodesView.vue | 260 ++++++++++-\n .../src/views/subscriptions/OrderAssignDrawer.vue | 3 +-\n .../src/views/subscriptions/OrderDetailDrawer.vue | 20 +-\n .../src/views/subscriptions/OrdersView.scss | 16 +\n .../src/views/subscriptions/OrdersView.vue | 225 +++++++++-\n admin-frontend/src/views/users/UsersView.vue | 490 ++++++++-------------\n admin-frontend/vite.config.ts | 3 +-\n .../V2/Admin/Server/ManageController.php | 13 +\n app/Http/Controllers/V2/Admin/UserController.php | 5 +-\n public/assets/admin | 0\n 31 files changed, 1442 insertions(+), 447 deletions(-)",
"staged": "",
"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---"
"combined": ".helloagents/.ralph-breaker.json | 4 +-\n .helloagents/.ralph-closeout.json | 12 +-\n .helloagents/.ralph-visual.json | 32 +-\n .helloagents/CHANGELOG.md | 39 +-\n .helloagents/archive/_index.md | 4 +\n .helloagents/context.md | 12 +-\n .helloagents/modules/admin-frontend.md | 19 +-\n .../.status.json | 10 +-\n .../contract.json | 27 +-\n .../proposal.md | 19 +-\n .../tasks.md | 14 +-\n .../2026-04-24T07-44-44-846Z-unknown-e3upr9.jsonl | 5 +\n .../2026-04-24T08-15-56-367Z-claude-u7aa37.jsonl | 3 +\n .helloagents/sessions/master/default/STATE.md | 18 +-\n admin-frontend/src/api/admin.ts | 31 ++\n admin-frontend/src/types/api.d.ts | 28 ++\n admin-frontend/src/types/components.d.ts | 1 +\n admin-frontend/src/utils/nodes.ts | 12 +\n admin-frontend/src/utils/orders.ts | 50 ++-\n admin-frontend/src/utils/users.ts | 352 ++++++++++++++-\n .../src/views/dashboard/DashboardView.vue | 162 +++++--\n admin-frontend/src/views/nodes/NodesView.vue | 260 ++++++++++-\n .../src/views/subscriptions/OrderAssignDrawer.vue | 3 +-\n .../src/views/subscriptions/OrderDetailDrawer.vue | 20 +-\n .../src/views/subscriptions/OrdersView.scss | 16 +\n .../src/views/subscriptions/OrdersView.vue | 225 +++++++++-\n admin-frontend/src/views/users/UsersView.vue | 490 ++++++++-------------\n admin-frontend/vite.config.ts | 3 +-\n .../V2/Admin/Server/ManageController.php | 13 +\n app/Http/Controllers/V2/Admin/UserController.php | 5 +-\n public/assets/admin | 0\n 31 files changed, 1442 insertions(+), 447 deletions(-)\n---"
}
}
+37 -2
View File
@@ -1,11 +1,46 @@
# CHANGELOG
## [0.5.9] - 2026-04-24
### 新增
- **[admin-frontend]**: 为节点管理工作台补齐本地分页、父/子节点筛选、单节点置顶与仅对已勾选节点生效的批量修改,支持统一更新 `host / group_ids / rate`,并接通真实 `server/manage/batchUpdate` 后端链路 — by yinjianm
- 方案: [202604242245_admin-frontend-node-pagination-batch-edit](archive/2026-04/202604242245_admin-frontend-node-pagination-batch-edit/)
- 决策: admin-frontend-node-pagination-batch-edit#D001(节点分页采用前端本地分页), admin-frontend-node-pagination-batch-edit#D002(批量修改范围固定为已勾选节点), admin-frontend-node-pagination-batch-edit#D003(置顶节点复用 server/manage/sort)
## [0.5.8] - 2026-04-24
### 新增
- **[admin-frontend]**: 为 `admin-frontend` 新增独立 Docker 镜像与 GHCR 自动发布链路,支持通过 `ADMIN_BUILD_OUT_DIR` 切换到容器专用 `dist` 输出,并在 `compose` 分支新增独立 `admin` 服务拉取 `ghcr.io/cedar2025/xboard-admin-frontend:new` 暴露运行 — by yinjianm
- 方案: [202604242250_admin-frontend-ghcr-compose](plans/202604242250_admin-frontend-ghcr-compose/)
- 决策: admin-frontend-ghcr-compose#D001(前端镜像发布链路独立于后端 docker-publish), admin-frontend-ghcr-compose#D002(容器内统一重定向到 /assets/admin/), admin-frontend-ghcr-compose#D003(compose 分支采用独立 admin 服务并暴露 7002)
## [0.5.7] - 2026-04-24
### 新增
- **[admin-frontend]**: 为用户管理“更多操作”菜单补齐旧后台常用行级动作,新增分配订单、查看 TA 的订单、查看 TA 的邀请、查看 TA 的流量记录与重置流量,并接通订单页按用户过滤与真实 `traffic-reset/reset-user` 后端链路 — by yinjianm
- 方案: [202604242236_admin-frontend-user-more-actions](plans/202604242236_admin-frontend-user-more-actions/)
- 决策: admin-frontend-user-more-actions#D001(订单分配抽屉预填邮箱), admin-frontend-user-more-actions#D002(用户订单采用跳页 + user_id 过滤), admin-frontend-user-more-actions#D003(邀请结果复用当前用户页筛选视图)
## [0.5.6] - 2026-04-24
### 修复
- **[admin-frontend]**: 修复订单管理页把无佣金订单误显示为“待确认”的问题;现在佣金状态会按真实佣金金额与发放链路判断,同时新增“确认佣金”菜单,可筛出真实待确认订单并在列表行级直接手动确认 — by yinjianm
- 方案: [202604242217_admin-frontend-orders-commission-confirmation](archive/2026-04/202604242217_admin-frontend-orders-commission-confirmation/)
- 决策: admin-frontend-orders-commission-confirmation#D001(真实佣金判定以金额和发放链路为准), admin-frontend-orders-commission-confirmation#D002(列表页新增确认佣金菜单与单行快捷确认)
## [0.5.5] - 2026-04-24
### 新增
- **[admin-frontend]**: 为用户管理工作台新增高级筛选与批量操作能力,支持多条件筛选、批量发送邮件、导出 CSV、批量封禁,以及对筛选结果执行“恢复正常” — by yinjianm
- 方案: [202604242200_admin-frontend-user-advanced-filter-batch-ops](plans/202604242200_admin-frontend-user-advanced-filter-batch-ops/)
- 决策: admin-frontend-user-advanced-filter-batch-ops#D001(高级筛选采用独立弹窗), admin-frontend-user-advanced-filter-batch-ops#D002(批量作用域按 selected > filtered > all 解析), admin-frontend-user-advanced-filter-batch-ops#D003(恢复正常沿用 user/ban 并扩展 banned=0|1)
## [0.5.4] - 2026-04-24
### 修复
- **[admin-frontend]**: 修复仪表盘“节点流量排行 / 用户流量排行”在 `24h` 视图下涨跌始终显示 `0%` 的问题;后端现在会把单日排行改为精确对比昨天整日统计,避免 `record_at=00:00` 的日统计行被秒级窗口错位排除 — by yinjianm
- **[admin-frontend]**: 修复仪表盘“节点流量排行 / 用户流量排行”在 `24h` 视图下涨跌始终显示 `0%` 的问题;后端现在会把单日排行改为精确对比昨天整日统计,前端同步补上悬浮详情卡,并把当前流量值右移强化显示 — 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天 保持现状)
- 决策: admin-frontend-dashboard-rank-24h-compare#D001(仅修复 24h 与昨天对比逻辑,7天/30天 保持现状), admin-frontend-dashboard-rank-24h-compare#D002(排行项补充 hover 详情卡,并将当前流量右移显示)
## [0.5.3] - 2026-04-24
@@ -0,0 +1,11 @@
{
"status": "completed",
"completed": 5,
"failed": 0,
"pending": 0,
"total": 5,
"done": 5,
"percent": 100,
"current": "订单页佣金状态修复、真实待确认筛选与单行确认菜单已完成",
"updated_at": "2026-04-24 22:26:00"
}
@@ -0,0 +1,179 @@
# 变更提案: admin-frontend-orders-commission-confirmation
## 元信息
```yaml
类型: 缺陷修复
方案类型: implementation
优先级: P1
状态: 已完成
创建: 2026-04-24
```
---
## 1. 需求
### 背景
`admin-frontend``#/subscriptions/orders` 订单管理页目前直接按 `commission_status` 渲染佣金状态。由于后端很多“无佣金订单”默认也会带 `commission_status = 0`,页面把这些订单一并显示成“待确认”,造成运营误判。同时,列表页缺少直接面向“真实待确认佣金订单”的筛选与快捷确认入口,只能进入详情抽屉手动改状态,效率较低。
### 目标
- 修复无佣金订单被误显示为“待确认”的问题,让列表与详情抽屉的佣金状态表达与真实业务一致。
- 在订单页增加“确认佣金”菜单,能一键筛出真实待确认的佣金订单。
- 在列表行级菜单中加入“确认佣金”快捷操作,把真实待确认订单手动确认到“发放中”。
- 保留详情抽屉内现有佣金状态维护能力,确保列表与详情行为一致。
### 约束条件
```yaml
时间约束: 本轮只处理 admin 订单页佣金可视化与确认流程,不扩展到返佣任务或提现模块
性能约束: 继续复用现有 order/fetch 与 order/update 接口,不新增重型列表查询
兼容性约束: 保持现有订单详情抽屉、佣金状态枚举与 Apple 风格后台视觉一致
业务约束: “真实待确认”必须排除无佣金订单;列表快捷确认仅把状态改为 1(发放中),不直接标记为已发放
验证约束: 优先执行 admin-frontend 的 Vite 构建验证;本地不依赖额外后端联调环境
```
### 验收标准
- [ ] 无佣金订单在列表和详情中不再显示为“待确认”,而是明确显示“无佣金”
- [ ] 点击“确认佣金”菜单后,可筛出真实待确认的佣金订单,不再混入无佣金订单
- [ ] 列表行级菜单可直接对真实待确认订单执行“确认佣金”,并把状态更新为“发放中”
- [ ] 详情抽屉中的佣金状态展示与可编辑条件和列表保持一致
- [ ] `admin-frontend` 构建通过
- [ ] `.helloagents` 文档与变更记录已同步
---
## 2. 方案
### 技术方案
1.`admin-frontend/src/utils/orders.ts` 中抽出“是否存在真实佣金”的统一判定逻辑,优先根据 `commission_balance` / `actual_commission_balance` 和已进入发放链路的状态综合判断,而不是单看默认的 `commission_status`
2. 调整佣金状态标签生成逻辑:无真实佣金的订单统一返回“无佣金”,佣金状态筛选仍保留原有枚举,但当前端进入佣金筛选场景时,自动附加后端已有的 `is_commission=true` 参数,利用服务端过滤能力排除无佣金订单。
3. 在订单页工具栏增加“确认佣金”菜单,提供“真实待确认订单 / 全部佣金订单 / 清空佣金筛选”三个快捷入口,帮助运营快速切换到真实佣金工作流。
4. 在列表新增行级操作列,保留“查看详情”,并对真实待确认订单提供“确认佣金”快捷项;执行时复用现有 `/order/update` 接口,把 `commission_status` 更新为 `1`
5. 同步更新订单详情抽屉的佣金状态文案与操作判定,确保列表页和详情页对“无佣金 / 待确认 / 发放中 / 已发放 / 无效”的解释一致。
### 影响范围
```yaml
涉及模块:
- admin-frontend/src/utils/orders.ts: 统一佣金状态判定、筛选标签与可编辑条件
- admin-frontend/src/views/subscriptions/OrdersView.vue: 增加确认佣金菜单、真实佣金筛选与行级快捷确认
- admin-frontend/src/views/subscriptions/OrdersView.scss: 补充菜单/操作列样式
- admin-frontend/src/views/subscriptions/OrderDetailDrawer.vue: 同步佣金状态文案与操作可见性
预计变更文件: 4
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 历史订单存在 `commission_balance = 0` 但已进入发放链路 | 中 | 真实佣金判定同时纳入 `actual_commission_balance` 与已发放/无效状态,避免误判为无佣金 |
| 列表快捷确认与详情抽屉状态维护不一致 | 中 | 两处统一复用 `orders.ts` 的判定与状态 meta,更新后统一刷新列表/详情 |
| 佣金筛选逻辑过于隐蔽导致运营不易理解 | 低 | 工具栏新增显式“确认佣金”菜单,并在激活时显示提示文案,说明当前只展示真实佣金订单 |
---
## 3. 技术设计(可选)
> 本轮不新增接口与数据结构,复用现有后端能力,保留该节用于说明前后端数据流。
### 架构设计
```mermaid
flowchart TD
A[OrdersView 工具栏菜单] --> B[fetchOrders is_commission=true]
A --> C[行级操作菜单]
C --> D[/order/update commission_status=1]
B --> E[订单表格]
E --> F[OrderDetailDrawer]
F --> D
```
### API设计
#### GET /order/fetch
- **请求**: 现有分页参数 + `filter` + `is_commission?: true`
- **响应**: 订单列表;当前端处于佣金工作流时,由后端过滤掉无邀请人、待支付/已取消、佣金金额为 0 的记录
#### POST /order/update
- **请求**: `{ trade_no: string, commission_status: 1 }`
- **响应**: `ApiResponse<boolean>`
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| commission_balance | number | 应付佣金额;大于 0 时优先认定为真实佣金订单 |
| actual_commission_balance | number \| null | 已实际发放佣金;用于兜底识别已进入发放链路的历史订单 |
| commission_status | number \| null | 0 待确认 / 1 发放中 / 2 已发放 / 3 无效 |
---
## 4. 核心场景
> 执行完成后同步到对应模块文档
### 场景: 运营筛出真实待确认佣金订单
**模块**: admin-frontend / subscriptions / orders
**条件**: 订单列表页已加载,存在部分真实佣金待确认订单
**行为**: 用户点击“确认佣金”菜单中的“真实待确认订单”
**结果**: 列表切换为仅展示 `commission_status = 0` 且真实存在佣金的订单
### 场景: 运营在列表中手动确认佣金
**模块**: admin-frontend / subscriptions / orders
**条件**: 某条订单属于真实待确认佣金订单
**行为**: 用户在该行操作菜单点击“确认佣金”
**结果**: 调用 `/order/update` 把佣金状态更新为“发放中”,列表状态即时刷新
### 场景: 无佣金订单被正确标记
**模块**: admin-frontend / subscriptions / orders
**条件**: 订单的 `commission_balance = 0`,且未进入发放链路
**行为**: 用户查看列表或详情抽屉中的佣金状态
**结果**: 页面明确显示“无佣金”,不再误导为“待确认”
---
## 5. 技术决策
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
### admin-frontend-orders-commission-confirmation#D001: 真实佣金判定以金额和发放链路为准,不再单看默认 commission_status
**日期**: 2026-04-24
**状态**: ✅采纳
**背景**: 当前无佣金订单也可能落在 `commission_status = 0`,如果前端只按状态展示,会把“默认值”误渲染成“待确认”。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 继续只看 commission_status | 改动最小 | 会持续把无佣金订单误判成待确认 |
| B: 以佣金金额/实际发放金额/已进入发放链路状态综合判断 | 业务语义更准确 | 需要统一封装前端判定逻辑 |
**决策**: 选择方案 B
**理由**: 能同时覆盖无佣金订单、已发放历史订单和无效佣金订单,最符合真实业务语义。
**影响**: `orders.ts` 的状态映射、订单列表渲染、详情抽屉可编辑条件
### admin-frontend-orders-commission-confirmation#D002: 列表页新增“确认佣金”快捷菜单与单行确认,而不是只依赖详情抽屉
**日期**: 2026-04-24
**状态**: ✅采纳
**背景**: 用户明确要求在当前页面把真实待确认佣金筛出来并能手动确认,同时在上一轮选择了“列表页新增单行快捷确认,并保留详情抽屉”。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 只保留详情抽屉确认 | 实现最少 | 无法满足当前页快速筛选与快捷确认诉求 |
| B: 列表页加快捷菜单与行级确认,详情抽屉保留完整状态维护 | 符合运营工作流,效率更高 | 需要补充操作列与筛选状态管理 |
**决策**: 选择方案 B
**理由**: 与用户确认选项一致,且能把“筛选 + 确认”闭环留在一个工作台内完成。
**影响**: `OrdersView.vue` / `OrdersView.scss` 需要新增工具栏菜单、操作列和交互反馈
---
## 6. 成果设计
> 含视觉产出的任务由 DESIGN Phase2 填充。非视觉任务整节标注"N/A"。
### 设计方向
- **美学基调**: Apple 风格的高密度运营工作台 —— 黑色静默首屏、白色精密工作区、蓝色交互信号,新增能力保持“像系统功能自然长出来”的克制感
- **记忆点**: 订单页工具栏里新增一颗明确的“确认佣金”工作流胶囊按钮,进入后列表与行级操作形成连续确认闭环
- **参考**: `apple/DESIGN.md``.helloagents/DESIGN.md`、当前订单页截图
### 视觉要素
- **配色**: 延续现有 `#000000 / #f5f5f7 / #ffffff` 主场与 `#0071e3` 交互强调色;无佣金状态使用中性灰,不与真实待确认的橙色混淆
- **字体**: 继续遵循项目既有 Apple 化系统字体栈,不引入新字体,只通过字重与字距维持信息层级
- **布局**: 保持现有单层工具栏结构,在筛选胶囊群中补入“确认佣金”菜单;表格右侧新增轻量操作列,不打断订单号点击查看详情的主路径
- **动效**: 仅保留按钮 hover、菜单展开和状态更新后的即时消息反馈,不引入额外炫技动画
- **氛围**: 继续使用纯白工作台、轻阴影容器和圆角胶囊控件,让新增能力自然融入当前后台节奏
### 技术约束
- **可访问性**: 菜单项与操作按钮保持可聚焦、文案明确,不只靠颜色传达佣金状态
- **响应式**: 延续当前订单页在窄屏下工具栏换行与表格横向滚动策略,不额外引入新断点
@@ -0,0 +1,50 @@
# 任务清单: admin-frontend-orders-commission-confirmation
> **@status:** completed | 2026-04-24 22:29
```yaml
@feature: admin-frontend-orders-commission-confirmation
@created: 2026-04-24
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 5 | 0 | 0 | 5 |
---
## 任务列表
### 1. 佣金状态判定与页面交互
- [√] 1.1 在 `admin-frontend/src/utils/orders.ts` 中统一真实佣金判定与状态映射,修复无佣金订单误显示为“待确认” | depends_on: []
- [√] 1.2 在 `admin-frontend/src/views/subscriptions/OrdersView.vue` 中接入真实佣金筛选状态,新增“确认佣金”菜单并在列表请求中透传 `is_commission` | depends_on: [1.1]
- [√] 1.3 在 `admin-frontend/src/views/subscriptions/OrdersView.vue``OrdersView.scss` 中增加行级操作列,为真实待确认订单提供“确认佣金”快捷操作与状态提示 | depends_on: [1.2]
### 2. 一致性与验收
- [√] 2.1 在 `admin-frontend/src/views/subscriptions/OrderDetailDrawer.vue` 中同步佣金状态文案与可编辑条件,确保列表与详情一致 | depends_on: [1.1]
- [√] 2.2 执行 `admin-frontend` 构建验证,并同步 `.helloagents` 记录与变更说明 | depends_on: [1.3, 2.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-24 22:17 | 方案设计 | completed | 已确认采用“列表页确认佣金菜单 + 单行快捷确认 + 保留详情抽屉”方案 |
| 2026-04-24 22:20 | 佣金判定修复 | completed | `orders.ts` 新增真实佣金判定,列表/详情统一改为无佣金不再显示待确认 |
| 2026-04-24 22:22 | 订单页交互增强 | completed | 订单工具栏新增“确认佣金”菜单,佣金状态筛选自动透传 `is_commission=true` |
| 2026-04-24 22:24 | 行级确认接入 | completed | 列表新增操作列,可对真实待确认订单直接确认为“发放中” |
| 2026-04-24 22:26 | 构建与文档同步 | completed | `npm run build` 通过,准备归档方案包并更新知识库记录 |
---
## 执行备注
- 当前后端已具备 `GET /order/fetch?is_commission=true``POST /order/update` 能力,本轮优先复用现有接口,不新增后端 API。
- 若本地构建外存在后端联调缺口,需以 `admin-frontend` 构建结果作为本轮最低验证证据,并在交付中明确说明。
@@ -0,0 +1,11 @@
{
"status": "completed",
"completed": 5,
"failed": 0,
"pending": 0,
"total": 5,
"done": 5,
"percent": 100,
"current": "节点管理分页、置顶、批量修改与父子筛选已完成",
"updated_at": "2026-04-24 23:02:00"
}
@@ -0,0 +1,49 @@
{
"updatedAt": "2026-04-24T14:45:23.000Z",
"version": 1,
"source": "manual",
"originCommand": "generic-r2",
"verifyMode": "test-first",
"reviewerFocus": [
"节点分页后的勾选状态是否稳定,不会因切页导致批量修改作用域错误",
"置顶动作与批量修改是否真实复用现有后台接口而不是只做前端假交互"
],
"testerFocus": [
"节点页是否支持按筛选结果分页浏览,并可切换每页数量",
"父节点与子节点筛选是否按 parent_id 正确区分",
"批量修改是否只提交已勾选节点的 ids,并只更新 host / group_ids / rate"
],
"ui": {
"required": true,
"designContract": true,
"sourcePriority": [
"requirements.md",
".helloagents/DESIGN.md",
"hello-ui"
],
"styleAdvisor": {
"required": false,
"reason": "",
"focus": []
},
"visualValidation": {
"required": true,
"reason": "节点管理本轮新增分页控件、父子筛选、批量修改弹窗与置顶动作,需要确认高密度工作台节奏与 Apple 化后台契约一致。",
"screens": [
"#/nodes desktop"
],
"states": [
"节点列表默认加载完成态",
"节点列表已勾选批量操作可用态",
"节点批量修改弹窗展开态",
"节点父子筛选切换态"
]
}
},
"advisor": {
"required": false,
"reason": "",
"focus": [],
"preferredSources": []
}
}
@@ -0,0 +1,62 @@
# admin-frontend 节点管理分页、置顶与批量修改 — 实施规划
## 目标与范围
- 将现有节点管理页从“单屏全量表格”升级为“可分页浏览、可稳定勾选、可批量维护”的真实运营工作台。
- 在不破坏既有 Apple 化后台节奏的前提下,补齐列表密度管理与批量维护动作。
## 架构与实现策略
- 继续保留 `NodesView.vue` 作为节点页装配入口,但补齐以下四类状态:
- 前端分页状态(页码、每页条数)
- 父/子节点筛选状态
- 跨分页勾选状态
- 批量修改弹窗状态
- 新增 `NodeBatchEditDialog.vue`
- 负责批量修改节点地址(host)、权限组、倍率
- 采用“字段开关 + 输入控件”的结构,避免未启用字段被误提交
- 明确提示仅对已勾选节点生效
- `src/utils/nodes.ts` 负责收敛节点列表的本地过滤逻辑:
- 关键字搜索
- 类型筛选
- 权限组筛选
- 父/子节点筛选
- `src/api/admin.ts``src/types/api.d.ts` 补齐节点批量修改的类型与接口封装。
- Laravel `ManageController::batchUpdate` 做最小扩展,仅补齐 `host / rate / group_ids` 三个字段的批量更新支持。
- “置顶节点”不新开接口,直接基于当前排序结果生成新的 `{ id, order }[]` 并提交到 `server/manage/sort`
## 完成定义
- 节点列表底部出现可用的分页控件,并能按当前筛选结果切页。
- 节点列表支持勾选多个节点,切页后勾选状态仍能稳定恢复。
- 工具条出现“批量修改”入口,且只有已勾选节点时可用。
- 批量修改弹窗支持按需修改 `host / group_ids / rate`,并真实写回后台。
- 节点行菜单新增“置顶节点”,执行后该节点会排到列表最前。
- 搜索工具条新增“全部节点 / 父节点 / 子节点”筛选选项。
## 文件结构
- `.helloagents/plans/202604242245_admin-frontend-node-pagination-batch-edit/*`
- `admin-frontend/src/api/admin.ts`
- `admin-frontend/src/types/api.d.ts`
- `admin-frontend/src/utils/nodes.ts`
- `admin-frontend/src/views/nodes/NodesView.vue`
- `admin-frontend/src/views/nodes/NodeBatchEditDialog.vue`
- `admin-frontend/src/views/nodes/NodeBatchEditDialog.scss`
- `app/Http/Controllers/V2/Admin/Server/ManageController.php`
## UI / 设计约束
- 节点页首屏继续保持“黑色 Hero + 白色工作台”结构,不另起新皮肤。
- 父/子节点筛选应与搜索、类型、权限组并列出现在工具条,维持高密度但不拥挤的运营节奏。
- 批量修改弹窗保持轻薄白色面板、分组式表单和固定底栏,避免做成厚重后台配置页。
- “置顶节点”属于高频轻操作,应放在行级菜单中而不是二级排序弹窗里。
## 风险与验证
- 风险 1:分页后勾选容易丢失,因此需要在前端维护独立的勾选 ID 集合并在切页后回填。
- 风险 2:批量修改只改部分字段,若直接提交完整节点模型容易覆盖协议配置,因此必须使用专门的批量 payload。
- 风险 3`batchUpdate` 原本不支持 `host / rate / group_ids`,前端先实现但后端不补齐会导致伪完成,因此必须同步扩展管理端接口。
- 验证方式:
- `npm run build`
- 代码级视觉自检:节点列表默认态、已勾选批量修改态、批量修改弹窗态
- 代码检查:置顶排序 payload 与批量修改 payload
## 决策记录
- [2026-04-24] 节点分页采用前端本地分页,不为本轮新增后端分页接口。
- [2026-04-24] 批量修改范围按用户确认固定为“仅已勾选节点”,不扩展到筛选结果。
- [2026-04-24] “置顶节点”直接复用 `server/manage/sort`,避免新开单独排序接口。
@@ -0,0 +1,41 @@
# admin-frontend 节点管理分页、置顶与批量修改 — 需求
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
## 核心目标
-`admin-frontend``#/nodes` 页面内补齐分页能力,避免节点列表持续增长后整页难以浏览。
- 为节点列表增加“置顶”单行操作,让运营者可以把某个节点快速移动到列表顶部。
- 为节点管理补齐批量修改工作流,并按本轮确认仅对**已勾选节点**生效。
- 为节点搜索补齐“子节点 / 父节点”筛选选项,帮助运营者快速区分主节点与子节点。
## 功能边界
- 必须保留现有节点管理能力:搜索、类型筛选、权限组筛选、显隐切换、复制、删除、添加节点、编辑节点、编辑排序。
- 分页必须作用于当前筛选结果,并允许切换每页数量。
- “置顶节点”必须真实生效到后台排序,不能只在前端临时重排。
- 批量修改本轮只支持以下字段:
- 节点地址(仅 `host`,不含端口)
- 权限组
- 倍率
- 批量修改只作用于已勾选节点;未勾选节点时不能误操作全部节点或筛选结果。
- “子节点 / 父节点”筛选必须基于 `parent_id` 真实区分:
- 父节点:`parent_id` 为空
- 子节点:`parent_id` 有值
## 非目标
- 本轮不引入后端列表分页接口,节点列表继续沿用 `server/manage/getNodes` 全量拉取 + 前端分页。
- 本轮不接入批量删除、批量重置流量、批量显隐、批量启停等其他批量动作。
- 本轮不修改节点编辑弹窗字段结构,也不扩展批量端口修改。
## 技术约束
- 技术栈固定为 `Vue 3 + TypeScript + Vite + Element Plus`,主目录限定在 `admin-frontend/`,后端仅做最小必要的 Laravel 管理接口补丁。
- 节点真相源仍以 `App\Http\Controllers\V2\Admin\Server\ManageController``App\Http\Requests\Admin\ServerSave``App\Models\Server` 为准。
- 置顶动作继续复用 `POST /server/manage/sort`
- 批量修改优先复用现有 `POST /server/manage/batchUpdate`,只补齐本轮所需字段支持。
- 视觉继续遵循 `apple/DESIGN.md``.helloagents/DESIGN.md` 的 Apple 化后台约束:黑色首屏 + 白色工作台 + 蓝色单一强调。
## 质量要求
- 分页、勾选和筛选不能互相打架:切页后已勾选节点状态应保持稳定,不得让用户误以为选择丢失。
- 批量修改入口需要明确显示“当前已选 N 个节点”,降低误操作风险。
- 批量修改弹窗需清楚说明“节点地址只改 host,不改端口”,避免概念歧义。
- 父/子节点筛选应作为节点搜索工作流的一部分清晰可见,而不是埋进深层菜单。
- 最终至少完成一次 `admin-frontend` 构建验证,并留下视觉验收与交付证据。
@@ -0,0 +1,13 @@
# admin-frontend 节点管理分页、置顶与批量修改 — 任务分解
## 任务列表
- [x] 任务1:补齐本轮方案与合同产物(涉及文件:`.helloagents/plans/202604242245_admin-frontend-node-pagination-batch-edit/*`;完成标准:存在需求、方案、任务、合同与状态文件;验证方式:文件检查)
- [x] 任务2:扩展节点批量修改 API 与后端兼容(涉及文件:`admin-frontend/src/api/admin.ts``admin-frontend/src/types/api.d.ts``app/Http/Controllers/V2/Admin/Server/ManageController.php`;完成标准:前后端支持按已勾选节点批量更新 `host / group_ids / rate`;验证方式:`npm run build` + 代码检查)
- [x] 任务3:重构节点列表工作台并接入分页 / 父子筛选 / 置顶(涉及文件:`admin-frontend/src/views/nodes/NodesView.vue``admin-frontend/src/utils/nodes.ts`;完成标准:节点页支持分页、父/子节点筛选与单节点置顶;验证方式:`npm run build`
- [x] 任务4:新增节点批量修改弹窗并接入勾选流(涉及文件:`admin-frontend/src/views/nodes/NodeBatchEditDialog.vue``admin-frontend/src/views/nodes/NodeBatchEditDialog.scss``admin-frontend/src/views/nodes/NodesView.vue`;完成标准:只对已勾选节点执行批量修改,支持地址 host、权限组、倍率三项;验证方式:`npm run build`
- [x] 任务5:完成验证与知识库同步(涉及文件:`.helloagents/CHANGELOG.md``.helloagents/context.md``.helloagents/modules/admin-frontend.md``.helloagents/.ralph-visual.json``.helloagents/.ralph-closeout.json``.helloagents/sessions/master/default/STATE.md`;完成标准:构建通过、知识库更新、证据落盘;验证方式:命令输出 + 证据文件)
## 进度
- [x] 已确认批量修改范围固定为“仅已勾选节点”。
- [x] 已完成节点分页、父/子节点筛选、置顶动作与批量修改弹窗。
- [x] 已完成构建验证、知识库同步与交付收尾。
+4
View File
@@ -7,6 +7,8 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
| 202604242245 | admin-frontend-node-pagination-batch-edit | implementation | admin-frontend | admin-frontend-node-pagination-batch-edit#D001,#D002,#D003 | ✅完成 |
| 202604242217 | admin-frontend-orders-commission-confirmation | implementation | admin-frontend | admin-frontend-orders-commission-confirmation#D001,#D002 | ✅完成 |
| 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 | - | - | - | ✅完成 |
@@ -32,6 +34,8 @@
## 按月归档
### 2026-04
- [202604242245_admin-frontend-node-pagination-batch-edit](./2026-04/202604242245_admin-frontend-node-pagination-batch-edit/) - 为节点管理工作台补齐本地分页、父/子节点筛选、单节点置顶,以及仅对已勾选节点生效的批量修改
- [202604242217_admin-frontend-orders-commission-confirmation](./2026-04/202604242217_admin-frontend-orders-commission-confirmation/) - 修复订单页无佣金订单误显示为待确认的问题,并新增真实待确认佣金筛选与行级手动确认入口
- [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/) - 开放“订单管理”入口,交付真实订单列表、筛选、分配订单、详情抽屉、手动支付与佣金状态维护
+11 -1
View File
@@ -9,6 +9,9 @@
## 技术上下文
- 管理端前端位于 `admin-frontend/`
- `admin-frontend` 现支持通过 `ADMIN_BUILD_OUT_DIR` 覆写构建输出目录:仓内默认仍写到 `../public/assets/admin`,容器构建可切到独立 `dist`
- 前端容器化运行采用 `admin-frontend/Dockerfile``Node 20 + Caddy` 多阶段构建),静态站点入口重定向到 `/assets/admin/`
- GHCR 前端镜像发布工作流位于 `.github/workflows/admin-frontend-docker-publish.yml`,镜像名为 `ghcr.io/<owner>/xboard-admin-frontend`
- 管理端 API 通过 `window.settings.secure_path``VITE_ADMIN_PATH` 解析 `/api/v2/{secure_path}` 前缀
- 登录接口复用 `/api/v2/passport/auth/login`
- 管理端仪表盘现已接入:
@@ -21,13 +24,18 @@
- `user/fetch`
- `user/generate`
- `user/update`
- `user/dumpCSV`
- `user/sendMail`
- `user/ban`
- `user/resetSecret`
- `user/destroy`
- `plan/fetch`
- `traffic-reset/reset-user`
- 管理端节点管理现已接入:
- `server/manage/getNodes`
- `server/manage/save`
- `server/manage/sort`
- `server/manage/batchUpdate`
- `server/group/fetch`
- `server/group/save`
- `server/group/drop`
@@ -86,6 +94,7 @@
- 主仓仍以 Laravel 为后端真相源
- `admin-frontend` 负责独立管理后台 UI 与交互逻辑
- `admin-frontend` 现在同时支持两种交付路径:仓内构建产物写回 `public/assets/admin`,或独立构建为 GHCR 静态镜像供 compose 分支部署
- 订阅协议导出由 Laravel 主仓内的 `app/Protocols/*` 提供,客户端兼容问题需以对应导出器实现为准
- `public/assets/admin` 为构建产物输出位置
@@ -93,11 +102,12 @@
- 管理端路由使用 Hash 模式
- 管理端当前业务路由包含 `/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 种协议的新增 / 编辑弹窗和排序对话框
- `#/nodes` 当前已升级为真实节点工作台:支持搜索、父/子节点筛选、分页浏览、显隐切换、复制、单节点置顶、仅对已勾选节点生效的批量修改,以及 11 种协议的新增 / 编辑弹窗和排序对话框
- Bearer Token 存储于 `sessionStorage/localStorage`
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
## 当前约束
- 本地静态 preview 环境默认缺少 Laravel 注入的 `window.settings` 与真实管理 API,受保护页面只能验证结构与跳转,不能替代完整联调
- 当前主工作树存在多组未提交业务改动;`compose` 分支变更需在独立 worktree 中处理,避免污染 `master`
- 后端接口契约以仓库内 Controller/Route 为准,不在前端推断字段
+17 -2
View File
@@ -5,9 +5,13 @@
- 提供 Vue3 管理端登录页、认证状态、路由守卫和主布局
- 封装管理端统计/系统状态、用户管理、节点管理、套餐管理和系统配置接口
- 渲染后台仪表盘、用户管理工作台、节点管理工作台、路由管理工作台、订阅套餐 / 订单 / 优惠券 / 礼品卡管理页、系统配置页、主题管理页、插件管理工作台、公告管理工作台、支付配置工作台,以及工单管理入口
- 提供独立静态部署产物:支持通过 Docker 镜像发布到 GHCR,并在 compose 分支中以独立 `admin` 服务运行
## 行为规范
- 默认构建输出仍为主仓 `public/assets/admin`;当 `ADMIN_BUILD_OUT_DIR` 存在时,构建输出需切换到外部指定目录,供容器镜像独立打包
- 独立容器运行时通过 `Caddyfile` 把根路径重定向到 `/assets/admin/`,避免当前 `base: '/assets/admin/'` 资源前缀失效
- 前端 GHCR 发布链路与 Laravel 主应用发布链路分离,避免把静态前端构建耦合进现有 PHP 镜像工作流
- 登录成功后优先跳转 `redirect` 指定路由,否则回到 `/dashboard`
- 受保护路由在未登录时会自动附加 `redirect` 查询参数
- API 基础路径使用 `/api/v2/{secure_path}`,其中 `secure_path` 来自运行时配置
@@ -17,12 +21,20 @@
- 仪表盘“节点流量排行 / 用户流量排行”均支持独立的 `10个 / 20个` 显示切换,长列表固定在面板内滚动,避免首页高度失控
- `stat/getTrafficRank` 现支持 `limit=10|20`,前端会按当前排行面板的显示数量重新请求;24h 口径也继续显示涨跌百分比
- `stat/getTrafficRank``24h` 口径下会按“昨天同日”统计做涨跌对比,避免日统计表因 `record_at=00:00` 被秒级窗口错位后全部回落为 `0%`
- 排行项 hover 时会显示“当前流量 / 上期流量 / 变化率”详情卡;当前流量值固定右侧展示,避免长节点名挤压后不易识别
- 仪表盘 Hero 区提供“刷新全部数据”入口,统一触发总览、趋势、排行和系统状态刷新,并在页面内展示最近一次刷新时间
- 用户管理页通过真实后端 `user/fetch``user/update``user/generate``user/resetSecret``user/destroy``plan/fetch` 完成数据读写
- 用户管理页通过真实后端 `user/fetch``user/update``user/generate``user/dumpCSV``user/sendMail``user/ban``user/resetSecret``user/destroy``plan/fetch` 完成数据读写
- 新增用户时采用“先 generate,后按邮箱回查并 update”的两段式流程,以兼容后端基础创建接口
- 节点管理页通过真实后端 `server/manage/getNodes``server/group/fetch``server/route/fetch` 获取列表 / 关联数据,并通过 `server/manage/save``server/manage/sort``server/manage/update``server/manage/copy``server/manage/drop` 完成新增、编辑、排序与行级操作
- 用户管理页现已补齐高级筛选弹窗,支持按邮箱、用户 ID、订阅、流量、已用流量、在线设备、到期时间、UUID、Token、账号状态和备注组合筛选
- 用户管理页新增勾选 + 批量操作工作流,支持“发送邮件 / 导出 CSV / 批量封禁 / 恢复正常”,作用范围按“已勾选用户 > 当前筛选结果 > 全部用户”自动判定
- 批量恢复正常沿用 `user/ban` 现有接口,通过 `banned=0|1` 兼容,不额外引入重复路由
- 用户管理页的“更多操作”菜单现已补齐 `分配订单 / TA的订单 / TA的邀请 / TA的流量记录 / 重置流量`;其中订单分配复用现有抽屉,用户订单跳转到订单页并自动按 `user_id` 过滤,邀请结果在当前用户页复用 `invite_user_id` 筛选视图
- 用户流量重置优先复用 `traffic-reset/reset-user`,用户行级“重置流量”会走真实后端重置链路并在成功后刷新列表
- 节点管理页通过真实后端 `server/manage/getNodes``server/group/fetch``server/route/fetch` 获取列表 / 关联数据,并通过 `server/manage/save``server/manage/sort``server/manage/update``server/manage/batchUpdate``server/manage/copy``server/manage/drop` 完成新增、编辑、排序、批量修改与行级操作
- 节点新增 / 编辑采用统一中央大弹窗,支持 `Shadowsocks / VMess / Trojan / Hysteria / VLess / TUIC / SOCKS / Naive / HTTP / Mieru / AnyTLS` 11 种协议的首版动态配置表单
- 节点排序采用本地草稿 + 上移 / 下移模式,保存时向 `server/manage/sort` 提交 `{ id, order }[]` 顺序 payload
- 节点列表现支持本地分页、父/子节点筛选,以及跨分页稳定勾选;批量修改仅作用于已勾选节点,可统一更新 `host / group_ids / rate`
- 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 payload 并提交到 `server/manage/sort`
- 权限组管理页使用真实后端 `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` 推导,不在前端伪造额外接口
@@ -34,6 +46,8 @@
- 套餐说明编辑采用轻量 Markdown/HTML 编辑器与预览模式,不引入额外富文本依赖
- 订单管理页使用真实后端 `order/fetch``order/detail``order/assign``order/paid``order/cancel``order/update`,支持订单列表、类型/周期/状态筛选、详情抽屉、手动分配、人工标记已支付与佣金状态维护
- 订单金额、佣金金额与相关拆解字段以“分”为后端真相源,前端统一在 `src/utils/orders.ts` 中格式化为“元”展示,避免后台金额口径混乱
- 订单管理页的佣金状态不再单看 `commission_status` 默认值;无真实佣金的订单统一显示“无佣金”,只有真实佣金订单才会参与“待确认 / 发放中 / 已发放 / 无效”状态流转
- 订单页新增“确认佣金”工具栏菜单,佣金状态筛选会自动透传 `is_commission=true`,确保“真实待确认订单”不会混入无佣金记录;行级操作列可直接把真实待确认订单手动确认到“发放中”
- 优惠券管理页使用真实后端 `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`
- 礼品卡工作台采用单页四页签结构,覆盖模板管理、兑换码管理、使用记录和统计数据;模板编辑使用分组式大抽屉,兑换码生成使用独立对话框
@@ -58,6 +72,7 @@
## 依赖关系
- 依赖 `admin-frontend/Dockerfile``admin-frontend/Caddyfile``.github/workflows/admin-frontend-docker-publish.yml` 提供独立镜像发布能力
- 依赖 `src/api/client.ts` 处理 axios 与认证头
- 依赖 `src/utils/users.ts` 负责用户管理表单转换、筛选组装和状态计算
- 依赖 `src/utils/plans.ts` 负责套餐价格、说明渲染、排序与表单转换
@@ -1,11 +1,11 @@
{
"status": "completed",
"completed": 4,
"completed": 5,
"failed": 0,
"pending": 0,
"total": 4,
"done": 4,
"total": 5,
"done": 5,
"percent": 100,
"current": "24h 排行对昨天比较修复、测试补充与语法校验已完成",
"updated_at": "2026-04-24 19:25:00"
"current": "24h 排行对昨天比较修复、hover 详情卡与右侧流量展示已完成",
"updated_at": "2026-04-24 19:36:00"
}
@@ -3,21 +3,26 @@
"version": 1,
"source": "R2",
"originCommand": "design",
"verifyMode": "targeted-check",
"verifyMode": "review-first",
"reviewerFocus": [
"24h 排行比较窗口是否准确命中昨天整日统计",
"7天 / 30天 对比逻辑是否保持原样,未被意外改动",
"节点排行与用户排行是否共用同一后端修复入口"
"节点/用户排行的 hover 详情卡与右侧流量值展示是否满足新增交互需求"
],
"testerFocus": [
"单日窗口应返回昨天整日作为 previous window",
"多日窗口应继续沿用原等跨度比较规则",
"修改文件至少通过 PHP 语法校验"
"节点/用户排行 hover 时应展示 当前流量 / 上期流量 / 变化率",
"admin-frontend 执行 npm run build 应通过"
],
"ui": {
"required": false,
"designContract": false,
"sourcePriority": [],
"required": true,
"designContract": true,
"sourcePriority": [
"plan.md",
".helloagents/DESIGN.md",
"hello-ui"
],
"styleAdvisor": {
"required": false,
"reason": "",
@@ -26,8 +31,14 @@
"visualValidation": {
"required": false,
"reason": "",
"screens": [],
"states": []
"screens": [
"#/dashboard node rank hover",
"#/dashboard user rank hover"
],
"states": [
"排行 hover tooltip",
"当前流量右侧显示"
]
}
},
"advisor": {
@@ -19,19 +19,22 @@
### 目标
- 修复 `24h` 排行涨跌百分比的对比窗口。
- 保持本轮范围最小,只处理 `24h` 口径,不改动 `7天 / 30天` 的既有对比方式。
- 为排行项补充 hover 详情卡,并把当前流量值移动到右侧强化展示。
### 约束条件
```yaml
范围约束: 仅修复 admin 仪表盘 traffic rank 的 24h 对比逻辑
技术约束: 优先做后端定点修复,不改动前端排行展示结构
技术约束: 后端做 24h 定点修复,前端仅做排行 hover 信息与右侧值展示的增量调整
业务约束: 24h 必须与昨天整日统计对比;7天/30天 本轮保持现状
验证约束: 本地无 composer vendor,无法直接跑 Laravel/PHPUnit 全量测试
验证约束: 本地无 composer vendor,无法直接跑 Laravel/PHPUnit;优先执行 admin-frontend 构建验证
```
### 验收标准
- [ ] `stat/getTrafficRank` 在单日窗口下返回的 `change` 不再因昨日统计行被排除而全部回退为 `0`
- [ ] 节点排行与用户排行共用同一修复逻辑
- [ ] 修改文件通过基础语法校验
- [ ] 排行项 hover 时可看到“当前流量 / 上期流量 / 变化率”详情
- [ ] 当前流量值固定在排行项右侧,视觉上不再被节点名吞没
- [ ] `admin-frontend` 构建通过
- [ ] `.helloagents` 变更记录已同步
---
@@ -50,7 +53,8 @@
1.`StatController` 中新增单独的对比窗口解析方法。
2. 当当前窗口是单日口径时,上一窗口直接固定为 `start_time - 86400 ~ start_time`,精确命中昨天整日统计。
3. 多日窗口沿用现有等跨度逻辑,避免超出本轮范围。
4. 增加单元测试覆盖窗口计算规则,便于后续回归
4. 前端排行项补充 `ElTooltip` 悬浮详情卡,并将当前流量值右移到独立右侧列
5. 增加单元测试覆盖窗口计算规则,便于后续回归。
---
@@ -62,3 +66,10 @@
**背景**: 用户在确认阶段明确选择“只修复 24h,对昨天同时间窗口比较;7天 / 30天 保持现状”。
**决策**: 后端只对单日窗口切换为“昨天整日”比较,多日窗口不在本轮调整。
**理由**: 与用户确认范围一致,改动最小,回归风险最低。
### admin-frontend-dashboard-rank-24h-compare#D002: 排行项补充 hover 详情卡,并将当前流量右移显示
**日期**: 2026-04-24
**状态**: ✅采纳
**背景**: 用户补充要求鼠标移入节点排行时展示“当前 / 上期 / 变化率”,并指出当前流量值放在左侧不够明显。
**决策**: 节点 / 用户排行统一复用 tooltip 详情卡,同时把当前流量值移到右侧与涨跌列组合显示。
**理由**: 复用同一排行结构即可满足需求,且不会引入新的页面层级或交互负担。
@@ -13,7 +13,7 @@
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
| 5 | 0 | 0 | 5 |
---
@@ -21,8 +21,9 @@
- [√] 1. 定位 24h 排行涨跌全部为 0 的根因,确认前后端边界
- [√] 2. 在后端实现单日窗口与昨天整日统计的定点对比修复
- [√] 3. 补充单元测试覆盖窗口解析逻辑
- [√] 4. 进行语法校验并同步 `.helloagents` 记录
- [√] 3. 为排行项补充 hover 详情卡,并把当前流量值右移强化展示
- [√] 4. 补充单元测试覆盖窗口解析逻辑
- [√] 5. 进行构建 / 可用性验证并同步 `.helloagents` 记录
---
@@ -32,11 +33,12 @@
|------|------|------|------|
| 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 |
| 2026-04-24 19:31 | 前端增强 | completed | 排行项新增 hover 详情卡,并把当前流量值移动到右侧独立列 |
| 2026-04-24 19:34 | 测试补充 | completed | 新增窗口解析单元测试,覆盖单日与多日两条路径 |
| 2026-04-24 19:36 | 校验与文档同步 | completed | `admin-frontend` 执行 `npm run build` 通过,并同步模块文档与 CHANGELOG |
---
## 执行备注
- 本地环境缺少 `vendor/autoload.php``vendor/bin/phpunit`,本轮无法执行 PHPUnit;已退化为语法校验,并保留测试文件供后续有依赖环境时执行。
- 本地环境缺少 `vendor/autoload.php``vendor/bin/phpunit`,本轮无法执行 PHPUnit;已保留测试文件供后续有依赖环境时执行。
@@ -0,0 +1,11 @@
{
"status": "completed",
"completed": 5,
"failed": 0,
"pending": 0,
"total": 5,
"done": 5,
"percent": 100,
"current": "用户管理高级筛选与批量操作已完成,等待后续需求",
"updated_at": "2026-04-24 22:12:00"
}
@@ -0,0 +1,49 @@
{
"updatedAt": "2026-04-24T14:00:16.000Z",
"version": 1,
"source": "manual",
"originCommand": "generic-r2",
"verifyMode": "test-first",
"reviewerFocus": [
"高级筛选弹窗是否覆盖用户给出的字段并保持 Apple 化后台视觉节奏",
"批量操作的作用范围是否明确且不会误把“筛选结果”降级成仅当前页数据"
],
"testerFocus": [
"用户列表是否能基于高级筛选条件重新请求真实 /user/fetch",
"批量发送邮件、导出 CSV、批量封禁与恢复正常是否按 selected/filtered/all 作用域提交正确 payload",
"后端 /user/ban 是否兼容 banned=0 并支撑恢复正常"
],
"ui": {
"required": true,
"designContract": true,
"sourcePriority": [
"requirements.md",
".helloagents/DESIGN.md",
"hello-ui"
],
"styleAdvisor": {
"required": false,
"reason": "",
"focus": []
},
"visualValidation": {
"required": true,
"reason": "用户管理页本轮新增高级筛选弹窗、批量邮件弹窗与批量操作入口,需要确认筛选结构、作用域提示和工作台节奏与既有 Apple 化后台契约一致。",
"screens": [
"#/users desktop"
],
"states": [
"用户管理默认加载完成态",
"高级筛选弹窗展开态",
"批量邮件弹窗展开态",
"已勾选用户后的批量操作可用态"
]
}
},
"advisor": {
"required": false,
"reason": "",
"focus": [],
"preferredSources": []
}
}
@@ -0,0 +1,63 @@
# admin-frontend 用户管理高级筛选与批量操作 — 实施规划
## 目标与范围
- 将现有用户管理页从“基础搜索 + 两个快捷筛选”升级为“基础筛选 + 高级筛选 + 批量操作”的真实运营工作台。
- 保持当前 Apple 化后台节奏,不引入厚重的筛选面板或新的视觉体系。
## 架构与实现策略
- 前端拆分为三层:
- `UsersView.vue`:只负责页面装配、表格渲染与弹层挂载
- `useUsersManagement.ts`:承接用户页状态、筛选、批量操作与列表刷新逻辑
- `UserAdvancedFilterDialog.vue` / `UserBatchMailDialog.vue`:分别承接高级筛选与批量邮件输入
- 工具层统一收敛到 `src/utils/users.ts`
- 高级筛选字段定义
- 字段 → 后端过滤条件映射
- 流量/时间/状态等值转换
- 已生效筛选摘要生成
- API 层补齐用户批量操作封装:
- `exportUsersCsv`
- `sendUsersMail`
- `batchUpdateUserBan`
- 后端最小改动放在 `App\Http\Controllers\V2\Admin\UserController::ban`
- 现有 `/user/ban` 默认仍保持“批量封禁”
- 新增兼容 `banned=0|1`,以支持“批量恢复正常”
## 完成定义
- 用户页工具条新增“高级筛选”入口,并可查看已生效筛选摘要。
- 高级筛选至少支持以下字段:
- 邮箱 / 用户 ID / 订阅 / 流量 / 已用流量 / 在线设备 / 到期时间 / UUID / Token / 账号状态 / 备注
- 用户表格支持勾选用户,并能从批量操作菜单触发“发送邮件 / 导出 CSV / 批量封禁 / 恢复正常”。
- 未勾选用户时,批量操作自动作用于当前筛选结果;无筛选条件时明确提示会作用于全部用户。
- 后端 `user/ban` 支持 `banned=0`,前端“恢复正常”可真实落库。
## 文件结构
- `.helloagents/plans/202604242200_admin-frontend-user-advanced-filter-batch-ops/*`
- `admin-frontend/src/api/admin.ts`
- `admin-frontend/src/types/api.d.ts`
- `admin-frontend/src/utils/users.ts`
- `admin-frontend/src/views/users/UsersView.vue`
- `admin-frontend/src/views/users/UsersView.scss`
- `admin-frontend/src/views/users/useUsersManagement.ts`
- `admin-frontend/src/views/users/UserAdvancedFilterDialog.vue`
- `admin-frontend/src/views/users/UserBatchMailDialog.vue`
- `app/Http/Controllers/V2/Admin/UserController.php`
## UI / 设计约束
- 首屏继续保持黑底标题区,不额外加营销式视觉层。
- 高级筛选弹窗采用白色内容面、清晰的条件分组和轻量边框,靠结构区分复杂度,而不是靠重阴影或渐变。
- 批量操作入口采用克制型下拉菜单,贴近用户截图中的纯文本操作列表。
- 已生效筛选摘要使用轻量胶囊标签呈现,帮助用户快速理解当前列表上下文。
## 风险与验证
- 风险 1:高级筛选字段多、值类型混合,若直接散落在视图中容易出现映射漂移,因此统一收口到 `src/utils/users.ts`
- 风险 2:批量操作的“作用范围”容易误伤全部用户,因此前端必须在执行前展示目标范围。
- 风险 3:批量恢复正常依赖后端接口补齐 `banned=0` 语义,若不改后端只能伪实现,因此必须做最小后端兼容。
- 验证方式:
- `npm run build`
- 代码级视觉自检:高级筛选弹窗、批量邮件弹窗、用户列表批量操作入口
- 批量恢复正常逻辑代码自检:前后端 payload 一致
## 决策记录
- [2026-04-24] 高级筛选采用独立弹窗而不是在工具条横向堆叠字段,以匹配用户截图并控制后台密度。
- [2026-04-24] 批量操作默认优先使用“已勾选用户 > 当前筛选结果 > 全部用户”的三段式作用域规则。
- [2026-04-24] 批量恢复正常沿用 `/user/ban` 现有路由,通过 `banned=0|1` 扩展语义,避免新开重复接口。
@@ -0,0 +1,46 @@
# admin-frontend 用户管理高级筛选与批量操作 — 需求
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
## 核心目标
-`admin-frontend` 的用户管理页补齐“高级筛选”能力,支持按照多个条件组合筛选用户。
- 高级筛选字段需覆盖用户提供截图中的核心字段:邮箱、用户 ID、订阅、流量、已用流量、在线设备、到期时间、UUID、Token、账号状态、备注。
- 在用户管理页接入“对筛选结果执行批量操作”的工作流,至少支持:
- 发送邮件
- 导出 CSV
- 批量封禁
- 批量恢复正常(用户本轮确认追加)
## 功能边界
- 必须保留现有用户管理基础能力:搜索、快捷状态筛选、订阅筛选、创建用户、行级编辑/复制/重置密钥/封禁/删除。
- 必须新增“高级筛选”弹窗或等价工作流,允许添加多条筛选条件,并将筛选结果回流到用户列表。
- 批量操作应明确作用范围:
- 若已勾选用户,则作用于已勾选用户
- 若未勾选但存在筛选条件,则作用于当前筛选结果
- 若既未勾选也无筛选条件,则作用于全部用户,并给出明确确认
- 批量邮件需提供主题与正文输入界面,不接受无内容直接发送。
- 批量恢复正常必须真实生效,不接受只在前端伪装状态。
## 非目标
- 本轮不重做用户编辑抽屉结构。
- 本轮不重构用户管理后端查询架构,只在现有接口能力基础上做最小必要扩展。
- 本轮不新增复杂图表或统计卡片。
## 技术约束
- 技术栈固定为 `Vue 3 + TypeScript + Vite + Element Plus`,目录限定在 `admin-frontend/` 与最小必要的 Laravel 管理端控制器。
- 用户列表真相源为 `App\Http\Controllers\V2\Admin\UserController`
- 前端用户接口继续走:
- `GET /user/fetch`
- `POST /user/update`
- `POST /user/resetSecret`
- `POST /user/destroy`
- `POST /user/dumpCSV`
- `POST /user/sendMail`
- `POST /user/ban`
- 视觉需遵循 `apple/DESIGN.md``.helloagents/DESIGN.md`:黑色首屏 + 白色工作台 + 蓝色单一强调,不引入另一套后台皮肤。
## 质量要求
- 高级筛选需贴近截图中的“条件卡片 + 字段选择 + 多条件叠加”体验,而不是只追加一排新的下拉框。
- 批量操作入口需清晰可发现,但不压过主列表的日常使用节奏。
- 用户页至少补齐“高级筛选已生效”的可见反馈,避免用户不知道当前列表为何被过滤。
- 最终至少完成一次 `admin-frontend` 构建验证,并留下视觉验收与交付证据。
@@ -0,0 +1,13 @@
# admin-frontend 用户管理高级筛选与批量操作 — 任务分解
## 任务列表
- [x] 任务1:补齐本轮方案与合同产物(涉及文件:`.helloagents/plans/202604242200_admin-frontend-user-advanced-filter-batch-ops/*`;完成标准:存在需求、方案、任务、合同与状态文件;验证方式:文件检查)
- [x] 任务2:扩展用户筛选工具层与批量操作 API(涉及文件:`admin-frontend/src/utils/users.ts``admin-frontend/src/types/api.d.ts``admin-frontend/src/api/admin.ts`;完成标准:前端可构造高级筛选与批量操作 payload;验证方式:`npm run build`
- [x] 任务3:重构用户管理页并新增高级筛选 / 批量邮件组件(涉及文件:`admin-frontend/src/views/users/*`;完成标准:用户页支持高级筛选、勾选、多种批量操作;验证方式:`npm run build`
- [x] 任务4:补齐批量恢复正常后端兼容(涉及文件:`app/Http/Controllers/V2/Admin/UserController.php`;完成标准:`/user/ban` 支持 `banned=0|1`;验证方式:代码检查 + `npm run build` 间接通过)
- [x] 任务5:完成验证与知识库同步(涉及文件:`.helloagents/CHANGELOG.md``.helloagents/context.md``.helloagents/modules/admin-frontend.md``.helloagents/.ralph-visual.json``.helloagents/.ralph-closeout.json``.helloagents/sessions/master/default/STATE.md`;完成标准:构建通过、知识库更新、证据落盘;验证方式:命令输出 + 证据文件)
## 进度
- [x] 已确认本轮除截图中的三项批量操作外,再额外补“批量恢复正常”。
- [x] 已完成高级筛选弹窗、批量操作菜单、批量邮件弹窗与前后端批量恢复链路。
- [x] 已完成 `npm run build` 构建验证,待输出最终交付摘要。
@@ -0,0 +1,11 @@
{
"status": "completed",
"completed": 4,
"failed": 0,
"pending": 0,
"total": 4,
"done": 4,
"percent": 100,
"current": "用户更多操作复刻已完成,等待后续需求",
"updated_at": "2026-04-24 22:45:00"
}
@@ -0,0 +1,51 @@
{
"updatedAt": "2026-04-24T14:36:00.000Z",
"version": 1,
"source": "manual",
"originCommand": "generic-r2",
"verifyMode": "test-first",
"reviewerFocus": [
"用户更多操作菜单是否按截图顺序与交互意图完成复刻",
"跨页筛选联动是否对用户可见且能明确清除"
],
"testerFocus": [
"分配订单是否可直接预填邮箱打开抽屉",
"TA的订单是否跳转订单页并按 user_id 生效",
"TA的邀请是否形成可用的用户结果视图",
"TA的流量记录与重置流量是否都走真实链路"
],
"ui": {
"required": true,
"designContract": true,
"sourcePriority": [
"requirements.md",
".helloagents/DESIGN.md",
"hello-ui"
],
"styleAdvisor": {
"required": false,
"reason": "",
"focus": []
},
"visualValidation": {
"required": true,
"reason": "本轮要补齐用户更多操作菜单与多个复用弹层/跳转联动,需要确认用户页菜单密度、订单页筛选提示和弹窗挂载都符合既有后台设计契约。",
"screens": [
"#/users desktop",
"#/subscriptions/orders desktop"
],
"states": [
"用户更多操作菜单展开态",
"用户页分配订单抽屉展开态",
"用户页流量记录弹窗展开态",
"订单页按指定用户过滤态"
]
}
},
"advisor": {
"required": false,
"reason": "",
"focus": [],
"preferredSources": []
}
}
@@ -0,0 +1,40 @@
# admin-frontend 用户管理更多操作复刻 — 实施规划
## 目标与范围
- 把用户管理行级“更多操作”从当前的基础维护菜单升级为更完整的运营工作台菜单。
- 以“复用现有抽屉 / 弹窗 / 页面”为主,不重复造轮子。
## 架构与实现策略
- 用户页继续以 `UsersView.vue + useUsersManagement.ts` 为主入口。
- 新增 `useUserScopedActions.ts` 负责:
- 用户页作用域跳转
- 订单分配抽屉预填
- 流量记录弹窗状态
- 邀请结果视图的路由筛选
- 用户流量重置调用
- 复用组件策略:
- `OrderAssignDrawer.vue`:支持预填邮箱
- `TrafficLogDialog.vue`:直接挂到用户页
- `OrdersView.vue`:读取路由 query 并自动追加用户过滤
- 后端优先复用现有 `traffic-reset/reset-user`,避免再开新接口。
## 完成定义
- 用户页“更多操作”菜单补齐目标操作项,并按截图相近顺序展示。
- `分配订单` 可直接打开抽屉,邮箱已回填。
- `TA的订单` 可跳转到 `#/subscriptions/orders`,并自动按 `user_id` 过滤。
- `TA的邀请` 可在用户页自动切换到“当前用户邀请结果”视图,并允许一键清除。
- `TA的流量记录` 可打开流量日志弹窗。
- `重置流量` 可调用真实后端接口并在成功后刷新用户列表。
## 风险与验证
- 风险 1:用户页与订单页的跨页筛选若无可见提示,容易造成理解偏差,因此需要补齐筛选提示与清除入口。
- 风险 2:用户更多操作继续堆进 `useUsersManagement.ts` 容易过大,因此拆分 `useUserScopedActions.ts`
- 风险 3:重置流量若重新发明接口会增加回归面,因此优先复用 `traffic-reset/reset-user`
- 验证方式:
- `npm run build`
- 代码自检:用户菜单完整性、订单页 query 过滤、流量重置调用链、邀请筛选可清除
## 决策记录
- [2026-04-24] `分配订单` 直接复用现有订单分配抽屉,并通过 prop 预填用户邮箱。
- [2026-04-24] `TA的订单` 采用跳页 + 自动过滤,而不是在用户页内塞第二套订单列表。
- [2026-04-24] `TA的邀请` 在当前用户页复用筛选结果视图,不额外新建邀请独立页面。
@@ -0,0 +1,41 @@
# admin-frontend 用户管理更多操作复刻 — 需求
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
## 核心目标
-`admin-frontend` 的用户管理“更多操作”菜单中,继续复刻旧后台的用户级操作项。
- 本轮目标菜单项包括:
- 编辑
- 分配订单
- 复制订阅 URL
- 重置 UUID 及订阅 URL
- TA 的订单
- TA 的邀请
- TA 的流量记录
- 重置流量
- 删除
## 功能边界
- `分配订单` 必须直接在用户页打开可用的订单分配抽屉,并预填当前用户邮箱。
- `TA的订单` 必须跳转到订单管理页,并自动按当前用户过滤订单。
- `TA的邀请` 必须进入“邀请了哪些用户”的结果视图,不接受只弹提示。
- `TA的流量记录` 必须直接打开现有流量记录弹窗。
- `重置流量` 必须调用真实后端链路,不接受只在前端重置显示值。
- 保留本轮上一阶段已交付的高级筛选与批量操作能力,不得回退。
## 非目标
- 本轮不重做用户管理整体布局。
- 本轮不新增完整“邀请管理”独立页面,只需要让“TA的邀请”形成可用结果视图。
- 本轮不重构订单管理与流量日志页面主体结构,只做最小必要联动。
## 技术约束
- 技术栈固定为 `Vue 3 + TypeScript + Vite + Element Plus`
- 前端改动集中在 `admin-frontend/src/views/users/*`、必要的订单页联动与 API 封装。
- 流量重置优先复用现有后端 `traffic-reset/reset-user` 能力,不重复发明新接口。
- 视觉继续遵循 `apple/DESIGN.md``.helloagents/DESIGN.md`
## 质量要求
- “更多操作”菜单的文案顺序与操作节奏尽量贴近用户截图。
- 用户点击某项操作后,必须形成明确结果:打开抽屉 / 打开弹窗 / 跳转并带筛选 / 成功提示。
- 订单页与用户页的联动筛选必须可见、可清除,不能让用户陷入“看不出为什么被过滤”的状态。
- 最终至少完成一次 `admin-frontend` 构建验证,并同步知识库与验收证据。
@@ -0,0 +1,12 @@
# admin-frontend 用户管理更多操作复刻 — 任务分解
## 任务列表
- [x] 任务1:补齐本轮方案与合同产物(涉及文件:`.helloagents/plans/202604242236_admin-frontend-user-more-actions/*`;完成标准:存在需求、方案、任务、合同与状态文件;验证方式:文件检查)
- [x] 任务2:扩展用户页更多操作状态与复用链路(涉及文件:`admin-frontend/src/views/users/*`;完成标准:菜单补齐并接通订单/邀请/流量/重置流量动作;验证方式:`npm run build`
- [x] 任务3:补齐订单页路由筛选联动与预填订单分配抽屉(涉及文件:`admin-frontend/src/views/subscriptions/OrdersView.vue``admin-frontend/src/views/subscriptions/OrderAssignDrawer.vue`;完成标准:用户订单跳转可见且可清除;验证方式:`npm run build`
- [x] 任务4:完成验证与知识库同步(涉及文件:`.helloagents/CHANGELOG.md``.helloagents/context.md``.helloagents/modules/admin-frontend.md``.helloagents/.ralph-visual.json``.helloagents/.ralph-closeout.json``.helloagents/sessions/master/default/STATE.md`;完成标准:构建通过、知识库更新、证据落盘;验证方式:命令输出 + 证据文件)
## 进度
- [x] 已确认按完整复刻方案推进更多操作。
- [x] 已完成用户页菜单、跳页筛选联动与真实流量重置。
- [x] 已完成 `npm run build` 构建验证,待输出最终交付摘要。
@@ -0,0 +1,11 @@
{
"status": "completed",
"completed": 4,
"failed": 0,
"pending": 0,
"total": 4,
"done": 4,
"percent": 100,
"current": "admin-frontend GHCR 发布与 compose 接入已完成,等待用户后续动作",
"updated_at": "2026-04-24 23:05:00"
}
@@ -0,0 +1,40 @@
{
"updatedAt": "2026-04-24T14:50:00.000Z",
"version": 1,
"source": "manual",
"originCommand": "generic-r2",
"verifyMode": "test-first",
"reviewerFocus": [
"admin-frontend 镜像链路是否与主应用镜像链路解耦且不互相污染",
"compose 分支新增 admin 服务后镜像名、端口和访问路径是否一致"
],
"testerFocus": [
"vite 输出目录是否可在容器构建时切换到 dist",
"admin-frontend Dockerfile 是否能独立构建静态镜像",
"GitHub Actions workflow 与 compose.yaml 是否都能通过 YAML 解析"
],
"ui": {
"required": false,
"designContract": false,
"sourcePriority": [
"requirements.md"
],
"styleAdvisor": {
"required": false,
"reason": "",
"focus": []
},
"visualValidation": {
"required": false,
"reason": "",
"screens": [],
"states": []
}
},
"advisor": {
"required": false,
"reason": "",
"focus": [],
"preferredSources": []
}
}
@@ -0,0 +1,37 @@
# admin-frontend GHCR 自动构建与 compose 接入 — 实施规划
## 目标与范围
-`admin-frontend` 从“仓内前端目录”升级为“可独立构建、可独立分发、可独立部署”的静态前端镜像。
- 保持当前主应用镜像发布链路不变,同时补一条面向前端的独立 GHCR 发布通道。
-`compose` 分支可直接拉起独立 `admin` 服务,对外暴露单独端口。
## 架构与实现策略
- 构建链路:
-`admin-frontend/` 下新增独立 Dockerfile,采用 `Node -> Caddy` 多阶段构建。
- 通过环境变量把容器构建输出切到 `dist`,避免影响当前仓内 `public/assets/admin` 输出。
- 发布链路:
- 新增单独的 GitHub Actions workflow,仅在 `admin-frontend/**` 或 workflow 自身变更时触发。
- 镜像发布到 `ghcr.io/<owner>/xboard-admin-frontend`,标签策略与主应用保持一致:分支、sha、`new``latest`
- 运行链路:
- 通过 Caddy 在容器内提供静态资源,根路径自动重定向到 `/assets/admin/`
- `compose` 分支新增 `admin` 服务,直接拉取 GHCR 镜像并暴露 `7002:80`
## 完成定义
- `admin-frontend` 本地可通过 Dockerfile 正常构建镜像。
- GitHub Actions 可在 push 后自动构建并推送 `admin-frontend` 镜像到 GHCR。
- `compose` 分支的 `compose.yaml` 已包含独立 `admin` 服务,且镜像名与端口配置正确。
- `npm run build` 与 workflow/compose 语法检查通过。
## 风险与验证
- 风险 1`vite.config.ts` 目前默认输出到仓根 `public/assets/admin`,容器构建若不切换输出目录会污染镜像构建路径,因此必须引入可覆写输出目录。
- 风险 2:若前端服务直接暴露 `/` 而未处理 `/assets/admin/`,现有资源前缀会失配,因此需要容器内重定向和静态路径兜底。
- 风险 3:当前仓库已有大量未提交业务改动,不能直接切换分支修改 `compose.yaml`,需在独立 worktree 中处理 `compose` 分支文件。
- 验证方式:
- `npm run build`
- `docker build -f admin-frontend/Dockerfile admin-frontend`
- `python -c "import yaml; ..."` 检查 workflow 与 compose YAML
## 决策记录
- [2026-04-24] 采用独立 `admin` 服务,并按用户确认暴露单独端口,不内嵌回 Laravel 主服务容器。
- [2026-04-24] 采用独立 workflow,而不是把前端镜像构建塞进现有后端 `docker-publish.yml`,以降低耦合和回归面。
- [2026-04-24] 前端运行时采用 Caddy 提供静态资源并做 `/ -> /assets/admin/` 重定向,兼容当前 `base: '/assets/admin/'`
@@ -0,0 +1,31 @@
# admin-frontend GHCR 自动构建与 compose 接入 — 需求
确认后冻结,执行阶段不可修改。如需变更必须回到设计阶段重新确认。
## 核心目标
-`admin-frontend` 增加独立的 Docker 构建与 GitHub Actions 发布链路。
- 在代码提交后,自动构建 `admin-frontend` 镜像并推送到 GHCR。
- 按用户已确认的方案,把 `compose` 分支的 `compose.yaml` 增加独立 `admin` 服务,并暴露独立访问端口。
## 功能边界
- 镜像必须只面向 `admin-frontend/` 构建,不混入 Laravel 主应用镜像逻辑。
- GHCR 发布链路需支持与主仓当前镜像发布策略并存,不能破坏现有后端 `docker-publish.yml`
- 新增的 `admin` 服务需直接引用 GHCR 镜像,不走本地 `build:`
- `admin` 服务需可独立访问,并保留把 `/assets/admin/` 继续挂到反向代理的能力。
## 非目标
- 本轮不改造 Laravel 主应用 Dockerfile。
- 本轮不重做 `admin-frontend` 的业务代码与视觉界面。
- 本轮不处理 GitHub Secrets 之外的外部部署脚本。
## 技术约束
- `admin-frontend` 仍使用 `Vue 3 + TypeScript + Vite`
- 本地现有构建输出 `../public/assets/admin` 不能被破坏;容器构建需使用独立输出目录。
- 发布目标为 GHCR,多架构与登录方式尽量沿用现有主仓工作流模式。
- 视觉与前端基线继续遵循 `apple/DESIGN.md`,但本轮主要产出为工程/部署配置。
## 质量要求
- `admin-frontend` 镜像需可直接运行并稳定提供静态资源。
- 工作流命名、镜像命名与标签策略需清晰,不和主应用镜像冲突。
- `compose.yaml` 中新增服务后,配置语义应一眼可读,端口、镜像名和用途明确。
- 最终至少完成一次 `admin-frontend` 构建验证与工作流 YAML 语法级自检。
@@ -0,0 +1,13 @@
# admin-frontend GHCR 自动构建与 compose 接入 — 任务分解
## 任务列表
- [x] 任务1:补齐本轮方案与合同产物(涉及文件:`.helloagents/plans/202604242250_admin-frontend-ghcr-compose/*`;完成标准:存在需求、方案、任务、合同与状态文件;验证方式:文件检查)
- [x] 任务2:实现 `admin-frontend` 独立 Docker 构建链路(涉及文件:`admin-frontend/Dockerfile``admin-frontend/Caddyfile``admin-frontend/.dockerignore``admin-frontend/vite.config.ts`;完成标准:可输出独立静态镜像;验证方式:`npm run build` + `ADMIN_BUILD_OUT_DIR=dist npm run build`,本地 `docker build` 因环境缺少 docker CLI 未执行)
- [x] 任务3:新增前端 GHCR 发布 workflow(涉及文件:`.github/workflows/admin-frontend-docker-publish.yml`;完成标准:push 后可自动构建并推送前端镜像;验证方式:YAML 解析 + 工作流自检)
- [x] 任务4:在 compose 分支接入独立 `admin` 服务并完成知识库同步(涉及文件:`compose.yaml`compose 分支 worktree)、`.helloagents/CHANGELOG.md``.helloagents/context.md``.helloagents/modules/admin-frontend.md``.helloagents/sessions/master/default/STATE.md`;完成标准:`compose` 分支文件完成接入且知识库已更新;验证方式:YAML 解析 + 文件检查)
## 进度
- [x] 已确认采用独立 `admin` 服务,并在 compose 分支暴露单独端口。
- [x] 已完成方案包、前端镜像构建链路与 GHCR workflow 编排。
- [x] 已完成 compose 分支 `compose.yaml` 接入与 YAML 语法校验。
- [x] 已完成 `npm run build``ADMIN_BUILD_OUT_DIR=dist npm run build` 验证;本地 `docker build` 因环境缺少 `docker` 命令未执行。
@@ -34,3 +34,11 @@
{"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":"需确认批量操作范围后再进入方案设计"}}
{"ts":"2026-04-24T14:02:19.806Z","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-24T14:19:31.199Z","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-24T14:29:32.396Z","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-24T14:36:50.516Z","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-24T14:49:03.691Z","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-24T15:11:19.797Z","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":["#/nodes desktop"],"statesChecked":["节点列表默认加载完成态","节点列表已勾选批量操作可用态","节点批量修改弹窗展开态","节点父子筛选切换态"],"status":"PASS"}}
{"ts":"2026-04-24T15:11:19.815Z","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":"已覆盖分页、父子筛选、单节点置顶、仅已勾选节点批量修改,以及 host/group_ids/rate 三项批量更新边界。"},"deliveryChecklist":{"status":"PASS","summary":"admin-frontend 构建通过,节点页与后端批量修改链路已落地,知识库、归档索引、会话状态与交付证据已同步。"}}}
{"ts":"2026-04-24T15:11:34.418Z","event":"turn_state_written","host":"unknown","source":"manual","sessionId":"2026-04-24T07-44-44-846Z-unknown-e3upr9","details":{"kind":"complete","role":"main","requiresDeliveryGate":false}}
@@ -3,3 +3,7 @@
{"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-"}
{"ts":"2026-04-24T14:02:56.195Z","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-24T14_02_56_086Z-"}
{"ts":"2026-04-24T14:20:41.339Z","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-24T14_20_41_220Z-"}
{"ts":"2026-04-24T14:49:36.264Z","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-24T14_49_36_133Z-"}
{"ts":"2026-04-24T15:13:24.623Z","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-24T15_13_24_529Z-"}
@@ -1,26 +1,26 @@
# 恢复快照
## 主线目标
继续推进 `admin-frontend` 节点管理模块,完成“添加节点 / 编辑节点 / 排序”真实工作台交付
完成 `admin-frontend` 节点管理页的分页、单节点置顶、仅对已勾选节点生效的批量修改,以及父/子节点筛选
## 正在做什么
当前任务已完成,正在整理节点管理本轮的验证证据、知识库同步与交付摘要
当前任务已完成,已完成代码修改、构建验证、知识库同步与方案包归档
## 关键上下文
- 用户已在本轮选择“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/`
- 用户在确认阶段选择“1”,确认批量修改范围仅限已勾选节点,不扩展到当前筛选结果
- 节点页已补齐本地分页、父/子节点筛选、跨分页勾选恢复、行级“置顶节点”和批量修改弹窗
- 批量修改只会更新 `host / group_ids / rate`,不会改动端口、显隐状态和协议配置
- Laravel `ManageController::batchUpdate` 已扩展支持 `host / rate / group_ids` 三个字段
- 本轮方案包已归档到 `.helloagents/archive/2026-04/202604242245_admin-frontend-node-pagination-batch-edit/`
## 下一步
当前任务已完成;如继续同一业务域,可在现有节点工作台基础上补机器管理、批量操作或更深的协议高级配置
当前任务已完成;如继续同一业务域,可在节点管理基础上补批量显隐、批量启停、批量重置流量或后端真实分页
## 阻塞项
(无)
## 方案
plans/202604241718_admin-frontend-node-management
archive/2026-04/202604242245_admin-frontend-node-pagination-batch-edit
## 已标记技能
frontend-design, hello-ui, hello-verify
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.github
.vscode
npm-debug.log
+17
View File
@@ -0,0 +1,17 @@
:80 {
encode zstd gzip
root * /usr/share/caddy
redir / /assets/admin/ 308
redir /assets/admin /assets/admin/ 308
@admin path /assets/admin /assets/admin/*
handle @admin {
try_files {path} {path}/ /assets/admin/index.html
file_server
}
handle {
respond "Not Found" 404
}
}
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN ADMIN_BUILD_OUT_DIR=dist npm run build
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist/ /usr/share/caddy/assets/admin/
EXPOSE 80
+31
View File
@@ -24,6 +24,7 @@ import type {
AdminNoticeItem,
AdminNoticeSavePayload,
AdminNodeItem,
AdminNodeBatchUpdatePayload,
AdminNodeSavePayload,
AdminNodeRouteItem,
AdminNodeRouteSavePayload,
@@ -45,6 +46,9 @@ import type {
AdminTicketFetchParams,
AdminTicketListItem,
AdminTrafficLogResult,
AdminUserBulkBanPayload,
AdminUserBulkMailPayload,
AdminUserBulkScopePayload,
AdminUserFetchParams,
AdminUserGeneratePayload,
AdminUserListItem,
@@ -506,6 +510,10 @@ export function updateNode(payload: AdminNodeUpdatePayload): Promise<ApiResponse
return unwrapPost<boolean>('/server/manage/update', payload as unknown as Record<string, unknown>)
}
export function batchUpdateNodes(payload: AdminNodeBatchUpdatePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/batchUpdate', payload as unknown as Record<string, unknown>)
}
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
}
@@ -554,6 +562,29 @@ export function deleteUser(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/destroy', { id })
}
export function exportUsersCsv(payload: AdminUserBulkScopePayload): Promise<Blob> {
return adminClient
.post<Blob>('/user/dumpCSV', payload as unknown as Record<string, unknown>, {
responseType: 'blob',
})
.then((res) => res.data)
}
export function sendUsersMail(payload: AdminUserBulkMailPayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/sendMail', payload as unknown as Record<string, unknown>)
}
export function batchUpdateUserBan(payload: AdminUserBulkBanPayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/ban', payload as unknown as Record<string, unknown>)
}
export function resetUserTraffic(userId: number, reason?: string): Promise<ApiResponse<Record<string, unknown>>> {
return unwrapPost<Record<string, unknown>>('/traffic-reset/reset-user', {
user_id: userId,
reason,
})
}
export function fetchTickets(params: AdminTicketFetchParams): Promise<AdminPaginationResult<AdminTicketListItem>> {
return adminClient
.get<AdminPaginationResult<AdminTicketListItem>>('/ticket/fetch', { params })
+28
View File
@@ -664,6 +664,8 @@ export interface AdminUserListItem {
discount: number | null
speed_limit: number | null
device_limit: number | null
online_count?: number | null
last_online_at?: number | null
remarks: string | null
banned: boolean
is_admin: boolean
@@ -691,6 +693,25 @@ export interface AdminUserFetchParams {
sort?: AdminUserSort[]
}
export interface AdminUserBulkScopePayload {
scope?: 'selected' | 'filtered' | 'all'
user_ids?: number[]
filter?: AdminUserFilter[]
}
export interface AdminUserBulkMailPayload extends AdminUserBulkScopePayload {
subject: string
content: string
sort?: string
sort_type?: 'ASC' | 'DESC'
}
export interface AdminUserBulkBanPayload extends AdminUserBulkScopePayload {
banned?: boolean | number
sort?: string
sort_type?: 'ASC' | 'DESC'
}
export interface AdminUserGeneratePayload {
email: string
password: string
@@ -872,6 +893,13 @@ export interface AdminNodeUpdatePayload {
machine_id?: number | null
}
export interface AdminNodeBatchUpdatePayload {
ids: number[]
host?: string
rate?: number
group_ids?: number[]
}
export interface AdminNodeSavePayload {
id?: number
type: AdminNodeType
+1
View File
@@ -45,6 +45,7 @@ declare module 'vue' {
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+12
View File
@@ -1,5 +1,7 @@
import type { AdminNodeItem } from '@/types/api'
export type NodeRelationFilter = 'all' | 'parent' | 'child'
export interface NodeStatusMeta {
label: string
dotClass: 'online' | 'pending' | 'offline' | 'disabled'
@@ -117,10 +119,12 @@ export function filterNodes(
keyword: string,
typeFilter: string,
groupFilter: string,
relationFilter: NodeRelationFilter = 'all',
): AdminNodeItem[] {
const normalizedKeyword = normalizeText(keyword)
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
const normalizedRelation = normalizeText(relationFilter)
return nodes.filter((node) => {
if (normalizedKeyword && !buildNodeSearchText(node).includes(normalizedKeyword)) {
@@ -138,6 +142,14 @@ export function filterNodes(
}
}
if (normalizedRelation === 'parent' && node.parent_id) {
return false
}
if (normalizedRelation === 'child' && !node.parent_id) {
return false
}
return true
})
}
+44 -6
View File
@@ -207,9 +207,35 @@ export function getOrderStatusMeta(status: number | null | undefined): OrderStat
}
}
export function getCommissionStatusMeta(status: number | null | undefined, amount?: number | null): OrderStatusMeta {
if ((amount ?? 0) <= 0 && (status === null || status === undefined)) {
return { label: '-', tone: 'neutral' }
interface CommissionStateTarget {
commission_status?: number | null
commission_balance?: number | null
actual_commission_balance?: number | null
}
export function hasOrderCommission(order?: CommissionStateTarget | null): boolean {
if (!order) {
return false
}
if (toAmount(order.commission_balance) > 0 || toAmount(order.actual_commission_balance) > 0) {
return true
}
return [1, 2, 3].includes(Number(order.commission_status ?? -1))
}
export function getCommissionStatusMeta(
status: number | null | undefined,
amount?: number | null,
actualAmount?: number | null,
): OrderStatusMeta {
if (!hasOrderCommission({
commission_status: status,
commission_balance: amount,
actual_commission_balance: actualAmount,
})) {
return { label: '无佣金', tone: 'neutral' }
}
switch (status) {
@@ -222,7 +248,7 @@ export function getCommissionStatusMeta(status: number | null | undefined, amoun
case 3:
return { label: '无效', tone: 'danger' }
default:
return { label: '未参与', tone: 'neutral' }
return { label: '待确认', tone: 'warning' }
}
}
@@ -315,14 +341,26 @@ export function canCancelOrder(order?: Pick<AdminOrderListItem, 'status'> | null
return order?.status === 0
}
export function canUpdateCommissionStatus(order?: Pick<AdminOrderDetail, 'commission_balance' | 'commission_status'> | null): boolean {
export function canUpdateCommissionStatus(
order?: Pick<AdminOrderDetail, 'commission_balance' | 'actual_commission_balance' | 'commission_status'> | null,
): boolean {
if (!order) {
return false
}
if ((order.commission_balance ?? 0) <= 0) {
if (!hasOrderCommission(order)) {
return false
}
return order.commission_status !== 2
}
export function canQuickConfirmCommission(
order?: Pick<AdminOrderListItem, 'status' | 'commission_balance' | 'actual_commission_balance' | 'commission_status'> | null,
): boolean {
if (!order) {
return false
}
return order.status === 3 && order.commission_status === 0 && hasOrderCommission(order)
}
+345 -7
View File
@@ -1,4 +1,4 @@
import type { AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
import type { AdminPlanOption, AdminUserFilter, AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
export interface UserStatusMeta {
label: string
@@ -28,6 +28,187 @@ export interface UserFormModel {
remarks: string
}
export type UserAdvancedFieldKey =
| 'email'
| 'id'
| 'plan_id'
| 'transfer_enable'
| 'total_used'
| 'online_count'
| 'expired_at'
| 'uuid'
| 'token'
| 'banned'
| 'remarks'
export type UserAdvancedOperator =
| 'like'
| 'notlike'
| 'eq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'null'
| 'notnull'
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'date'
export interface UserAdvancedFilterItem {
key: string
field: UserAdvancedFieldKey
operator: UserAdvancedOperator
value: string | number | null
logic: 'and' | 'or'
}
export interface UserAdvancedFieldDefinition {
field: UserAdvancedFieldKey
label: string
input: UserAdvancedInputKind
placeholder?: string
unit?: string
step?: number
operators: Array<{ value: UserAdvancedOperator; label: string }>
}
export const USER_STATUS_VALUE_OPTIONS = [
{ label: '正常', value: 0 },
{ label: '封禁', value: 1 },
] as const
export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
{
field: 'email',
label: '邮箱',
input: 'text',
placeholder: '输入邮箱关键字',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'id',
label: '用户ID',
input: 'number',
placeholder: '输入用户 ID',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'plan_id',
label: '订阅',
input: 'plan',
operators: [
{ value: 'eq', label: '是' },
{ value: 'null', label: '未订阅' },
{ value: 'notnull', label: '已订阅' },
],
},
{
field: 'transfer_enable',
label: '流量',
input: 'number',
placeholder: '输入流量值',
unit: 'GB',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'total_used',
label: '已用流量',
input: 'number',
placeholder: '输入已用流量',
unit: 'GB',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'online_count',
label: '在线设备',
input: 'number',
placeholder: '输入在线设备数',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'expired_at',
label: '到期时间',
input: 'date',
operators: [
{ value: 'gte', label: '晚于' },
{ value: 'lte', label: '早于' },
{ value: 'null', label: '长期有效' },
{ value: 'notnull', label: '已设置到期时间' },
],
},
{
field: 'uuid',
label: 'UUID',
input: 'text',
placeholder: '输入 UUID',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'token',
label: 'Token',
input: 'text',
placeholder: '输入 Token',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'banned',
label: '账号状态',
input: 'status',
operators: [{ value: 'eq', label: '是' }],
},
{
field: 'remarks',
label: '备注',
input: 'text',
placeholder: '输入备注关键字',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
] as const
export const COMMISSION_TYPE_OPTIONS = [
{ label: '跟随系统', value: 0 },
{ label: '周期返佣', value: 1 },
@@ -36,6 +217,10 @@ export const COMMISSION_TYPE_OPTIONS = [
const GIGABYTE = 1024 ** 3
function createFilterKey(): string {
return `user-filter-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
}
function toNumber(value: unknown): number {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : 0
@@ -68,6 +253,96 @@ export function normalizeTimestampSeconds(value: number | string | null | undefi
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null
}
export function getUserAdvancedFieldDefinition(field: UserAdvancedFieldKey): UserAdvancedFieldDefinition {
return USER_ADVANCED_FIELD_DEFINITIONS.find((item) => item.field === field) ?? USER_ADVANCED_FIELD_DEFINITIONS[0]
}
export function createEmptyUserAdvancedFilter(): UserAdvancedFilterItem {
return {
key: createFilterKey(),
field: 'email',
operator: 'like',
value: '',
logic: 'and',
}
}
export function cloneUserAdvancedFilters(filters: UserAdvancedFilterItem[]): UserAdvancedFilterItem[] {
return filters.map((item) => ({
key: item.key || createFilterKey(),
field: item.field,
operator: item.operator,
value: item.value ?? '',
logic: item.logic ?? 'and',
}))
}
function requiresFilterValue(operator: UserAdvancedOperator): boolean {
return operator !== 'null' && operator !== 'notnull'
}
function normalizeAdvancedFilterValue(item: UserAdvancedFilterItem): string | null {
if (!requiresFilterValue(item.operator)) {
return `${item.operator}:1`
}
if (item.value === null || item.value === undefined || item.value === '') {
return null
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
const numeric = Number(item.value)
if (!Number.isFinite(numeric) || numeric < 0) {
return null
}
return `${item.operator}:${Math.round(numeric * GIGABYTE)}`
}
if (item.field === 'expired_at') {
const timestamp = normalizeTimestampSeconds(item.value)
return timestamp ? `${item.operator}:${timestamp}` : null
}
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'online_count' || item.field === 'banned') {
const numeric = Number(item.value)
return Number.isFinite(numeric) ? `${item.operator}:${numeric}` : null
}
const normalized = String(item.value).trim()
return normalized ? `${item.operator}:${normalized}` : null
}
function getPlanNameById(plans: AdminPlanOption[], id: string | number): string {
const numericId = Number(id)
const target = plans.find((plan) => Number(plan.id) === numericId)
return target?.name || `订阅 #${numericId}`
}
function formatAdvancedFilterValue(item: UserAdvancedFilterItem, plans: AdminPlanOption[]): string {
if (!requiresFilterValue(item.operator)) {
return item.operator === 'null' ? '未设置' : '已设置'
}
if (item.field === 'plan_id') {
return getPlanNameById(plans, item.value ?? '')
}
if (item.field === 'banned') {
return Number(item.value) === 1 ? '封禁' : '正常'
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
return `${Number(item.value)} GB`
}
if (item.field === 'expired_at') {
const seconds = normalizeTimestampSeconds(item.value)
return seconds ? new Date(seconds * 1000).toLocaleString('zh-CN', { hour12: false }) : '未设置'
}
return String(item.value)
}
export function splitEmailAddress(email: string): { prefix: string; suffix: string } | null {
const normalized = email.trim()
const atIndex = normalized.lastIndexOf('@')
@@ -184,24 +459,87 @@ export function toUserUpdatePayload(form: UserFormModel): AdminUserUpdatePayload
}
}
export function buildUserFilters(keyword: string, status: string, planId: string): Array<{ id: string; value: string | number[] }> {
const filters: Array<{ id: string; value: string | number[] }> = []
export function buildUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
): AdminUserFilter[] {
const filters: AdminUserFilter[] = []
if (keyword.trim()) {
filters.push({ id: 'email', value: keyword.trim() })
filters.push({ id: 'email', value: `like:${keyword.trim()}` })
}
if (status === 'active') {
filters.push({ id: 'banned', value: [0] })
filters.push({ id: 'banned', value: 'eq:0' })
}
if (status === 'banned') {
filters.push({ id: 'banned', value: [1] })
filters.push({ id: 'banned', value: 'eq:1' })
}
if (planId && planId !== 'all') {
filters.push({ id: 'plan_id', value: [Number(planId)] })
filters.push({ id: 'plan_id', value: `eq:${Number(planId)}` })
}
for (const item of advancedFilters) {
const normalizedValue = normalizeAdvancedFilterValue(item)
if (!normalizedValue) {
continue
}
filters.push({
id: item.field,
value: normalizedValue,
logic: item.logic,
})
}
return filters
}
export function hasUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
): boolean {
return buildUserFilters(keyword, status, planId, advancedFilters).length > 0
}
export function summarizeUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
plans: AdminPlanOption[] = [],
): string[] {
const summaries: string[] = []
if (keyword.trim()) {
summaries.push(`邮箱包含 ${keyword.trim()}`)
}
if (status === 'active') {
summaries.push('快捷状态:正常')
}
if (status === 'banned') {
summaries.push('快捷状态:封禁')
}
if (planId && planId !== 'all') {
summaries.push(`快捷订阅:${getPlanNameById(plans, planId)}`)
}
for (const item of advancedFilters) {
const definition = getUserAdvancedFieldDefinition(item.field)
const operatorLabel = definition.operators.find((option) => option.value === item.operator)?.label ?? item.operator
const prefix = item.logic === 'or' ? '或' : '且'
const valueText = formatAdvancedFilterValue(item, plans)
summaries.push(`${prefix} ${definition.label} ${operatorLabel} ${valueText}`.trim())
}
return summaries
}
@@ -483,6 +483,12 @@ function rankScrollClass(limit: RankDisplayCount): string {
return limit === 20 ? 'rank-scroll rank-scroll--extended' : 'rank-scroll'
}
function rankChangeClass(change: number): string {
if (Number(change) > 0) return 'positive'
if (Number(change) < 0) return 'negative'
return 'neutral'
}
watch(trendPreset, () => {
void loadTrend().catch(() => ElMessage.error('趋势数据刷新失败'))
})
@@ -757,22 +763,45 @@ onMounted(() => {
:class="rankScrollClass(nodeRankLimit)"
>
<div class="rank-list">
<div
<ElTooltip
v-for="(item, index) in displayedNodeRanks"
:key="item.id"
class="rank-item"
placement="top-end"
:show-after="80"
popper-class="dashboard-rank-tooltip-popper"
>
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
<template #content>
<div class="rank-tooltip">
<div class="rank-tooltip__row">
<span>当前流量</span>
<strong>{{ formatTraffic(item.value) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>上期流量</span>
<strong>{{ formatTraffic(item.previousValue) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>变化率</span>
<strong :class="rankChangeClass(item.change)">{{ formatPercent(item.change) }}</strong>
</div>
</div>
</template>
<div class="rank-item">
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<div class="rank-item__meta">
<em :class="rankChangeClass(item.change)">
{{ formatPercent(item.change) }}
</em>
<span class="rank-item__value">{{ formatTraffic(item.value) }}</span>
</div>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
</ElTooltip>
</div>
</div>
<div v-else class="panel-state">暂无节点排行数据</div>
@@ -822,22 +851,45 @@ onMounted(() => {
:class="rankScrollClass(userRankLimit)"
>
<div class="rank-list">
<div
<ElTooltip
v-for="(item, index) in displayedUserRanks"
:key="item.id"
class="rank-item"
placement="top-end"
:show-after="80"
popper-class="dashboard-rank-tooltip-popper"
>
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
<template #content>
<div class="rank-tooltip">
<div class="rank-tooltip__row">
<span>当前流量</span>
<strong>{{ formatTraffic(item.value) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>上期流量</span>
<strong>{{ formatTraffic(item.previousValue) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>变化率</span>
<strong :class="rankChangeClass(item.change)">{{ formatPercent(item.change) }}</strong>
</div>
</div>
</template>
<div class="rank-item">
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<div class="rank-item__meta">
<em :class="rankChangeClass(item.change)">
{{ formatPercent(item.change) }}
</em>
<span class="rank-item__value">{{ formatTraffic(item.value) }}</span>
</div>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
</ElTooltip>
</div>
</div>
<div v-else class="panel-state">暂无用户排行数据</div>
@@ -1297,11 +1349,12 @@ onMounted(() => {
grid-template-columns: minmax(0, 1fr) 150px auto;
gap: 14px;
align-items: center;
cursor: default;
}
.rank-item__copy {
display: grid;
gap: 6px;
display: flex;
align-items: center;
min-width: 0;
}
@@ -1328,6 +1381,58 @@ onMounted(() => {
.rank-item em {
font-style: normal;
font-size: 14px;
font-weight: 600;
}
.rank-item__meta {
display: grid;
gap: 4px;
justify-items: end;
text-align: right;
}
.rank-item__value {
color: var(--xboard-text-strong);
font-size: 15px;
font-weight: 600;
}
.rank-tooltip {
display: grid;
gap: 10px;
min-width: 188px;
}
.rank-tooltip__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.rank-tooltip__row span {
color: rgba(255, 255, 255, 0.68);
font-size: 12px;
}
.rank-tooltip__row strong {
color: #ffffff;
font-size: 13px;
font-weight: 600;
}
:global(.dashboard-rank-tooltip-popper) {
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 16px !important;
background: rgba(6, 12, 24, 0.94) !important;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.28) !important;
backdrop-filter: blur(18px);
}
:global(.dashboard-rank-tooltip-popper .el-popper__arrow::before) {
background: rgba(6, 12, 24, 0.94) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
}
.status-grid {
@@ -1443,6 +1548,11 @@ onMounted(() => {
.rank-item {
grid-template-columns: 1fr;
}
.rank-item__meta {
justify-items: start;
text-align: left;
}
}
@keyframes dashboard-refresh-spin {
+77
View File
@@ -0,0 +1,77 @@
.node-batch-edit-dialog {
.batch-shell,
.batch-section {
display: grid;
gap: 16px;
}
.batch-shell {
gap: 20px;
}
.batch-hero,
.batch-switch-card,
.batch-footer,
.batch-footer__actions {
display: flex;
align-items: center;
gap: 12px;
}
.batch-hero,
.batch-footer,
.batch-switch-card {
justify-content: space-between;
}
.batch-hero h2 {
margin: 0 0 6px;
font-size: 28px;
line-height: 1.08;
letter-spacing: -0.24px;
color: var(--xboard-text-strong);
}
.batch-hero p,
.batch-switch-card span,
.batch-footer__hint {
color: var(--xboard-text-muted);
line-height: 1.55;
}
.batch-section {
padding: 18px 20px;
border-radius: 22px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.batch-switch-card {
align-items: flex-start;
}
.batch-switch-card strong {
display: block;
margin-bottom: 4px;
color: var(--xboard-text-strong);
}
.full-width,
.full-width .el-input__wrapper {
width: 100%;
}
.batch-footer {
width: 100%;
}
@media (max-width: 767px) {
.batch-hero,
.batch-switch-card,
.batch-footer,
.batch-footer__actions {
flex-direction: column;
align-items: stretch;
}
}
}
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { AdminServerGroupItem } from '@/types/api'
interface NodeBatchEditPayload {
host?: string
rate?: number
group_ids?: number[]
}
const props = defineProps<{
visible: boolean
groups: AdminServerGroupItem[]
selectedCount: number
loading?: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [payload: NodeBatchEditPayload]
}>()
const form = reactive({
updateHost: false,
host: '',
updateRate: false,
rate: 1,
updateGroups: false,
groupIds: [] as number[],
})
const hasEnabledField = computed(() => form.updateHost || form.updateRate || form.updateGroups)
function resetForm() {
form.updateHost = false
form.host = ''
form.updateRate = false
form.rate = 1
form.updateGroups = false
form.groupIds = []
}
function closeDialog() {
emit('update:visible', false)
}
function handleSubmit() {
if (!hasEnabledField.value) {
ElMessage.warning('请至少开启一个需要批量修改的字段')
return
}
if (form.updateHost && !form.host.trim()) {
ElMessage.warning('请输入新的节点地址 host')
return
}
if (form.updateRate && (!Number.isFinite(Number(form.rate)) || Number(form.rate) <= 0)) {
ElMessage.warning('请输入大于 0 的倍率')
return
}
emit('submit', {
host: form.updateHost ? form.host.trim() : undefined,
rate: form.updateRate ? Number(form.rate) : undefined,
group_ids: form.updateGroups ? [...form.groupIds] : undefined,
})
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(680px, calc(100vw - 24px))"
class="node-batch-edit-dialog"
destroy-on-close
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="batch-shell">
<header class="batch-hero">
<div>
<h2>批量修改节点</h2>
<p>本轮仅对已勾选节点生效支持统一修改节点地址 host权限组和倍率</p>
</div>
<ElTag round effect="dark">
已选 {{ props.selectedCount }} 个节点
</ElTag>
</header>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量修改节点地址</strong>
<span>只修改 `host`不改端口适合整批切换域名或 IP</span>
</div>
<ElSwitch v-model="form.updateHost" />
</label>
<ElInput
v-model="form.host"
:disabled="!form.updateHost"
placeholder="例如 node.example.com 或 1.2.3.4"
/>
</section>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量修改权限组</strong>
<span>启用后会整体替换所选节点的权限组留空表示清空权限组</span>
</div>
<ElSwitch v-model="form.updateGroups" />
</label>
<ElSelect
v-model="form.groupIds"
multiple
collapse-tags
collapse-tags-tooltip
:disabled="!form.updateGroups"
placeholder="请选择权限组"
>
<ElOption
v-for="group in props.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</ElSelect>
</section>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量修改倍率</strong>
<span>适合统一调整节点倍率不会改动动态倍率规则</span>
</div>
<ElSwitch v-model="form.updateRate" />
</label>
<ElInputNumber
v-model="form.rate"
:disabled="!form.updateRate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</section>
</div>
<template #footer>
<div class="batch-footer">
<span class="batch-footer__hint">批量修改不会影响端口协议配置与显隐状态</span>
<div class="batch-footer__actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="props.loading" @click="handleSubmit">
确认批量修改
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss" src="./NodeBatchEditDialog.scss"></style>
+248 -12
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import {
Connection,
MoreFilled,
@@ -11,14 +12,22 @@ import {
User,
} from '@element-plus/icons-vue'
import {
batchUpdateNodes,
copyNode,
deleteNode,
fetchNodes,
fetchNodeRoutes,
getServerGroups,
sortNodes,
updateNode,
} from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem, AdminServerGroupItem } from '@/types/api'
import type {
AdminNodeBatchUpdatePayload,
AdminNodeItem,
AdminNodeRouteItem,
AdminServerGroupItem,
} from '@/types/api'
import NodeBatchEditDialog from './NodeBatchEditDialog.vue'
import NodeEditorDialog from './NodeEditorDialog.vue'
import NodeSortDialog from './NodeSortDialog.vue'
import {
@@ -32,14 +41,17 @@ import {
getNodeIdLabel,
getNodeStatusMeta,
getNodeTypeLabel,
type NodeRelationFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
type NodeAction = 'edit' | 'copy' | 'delete'
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete'
type NodeDialogMode = 'create' | 'edit'
type NodeBatchEditPayload = Omit<AdminNodeBatchUpdatePayload, 'ids'>
const route = useRoute()
const router = useRouter()
const tableRef = ref<TableInstance>()
const loading = ref(false)
const errorMessage = ref('')
const nodes = ref<AdminNodeItem[]>([])
@@ -48,30 +60,55 @@ const routes = ref<AdminNodeRouteItem[]>([])
const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const relationFilter = ref<NodeRelationFilter>('all')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedNodeIds = ref<number[]>([])
const switchingIds = ref<number[]>([])
const workingIds = ref<number[]>([])
const editorVisible = ref(false)
const editorMode = ref<NodeDialogMode>('create')
const activeNode = ref<AdminNodeItem | null>(null)
const sortDialogVisible = ref(false)
const batchEditVisible = ref(false)
const batchSubmitting = ref(false)
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
nodes.value,
keyword.value,
typeFilter.value,
groupFilter.value,
relationFilter.value,
)))
const paginatedNodes = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredNodes.value.slice(start, start + pageSize.value)
})
const selectedNodes = computed(() => nodes.value.filter((node) => selectedNodeIds.value.includes(node.id)))
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.value !== 'all')
const hasSelectedNodes = computed(() => selectedNodes.value.length > 0)
const hasActiveFilters = computed(() => (
keyword.value !== ''
|| typeFilter.value !== 'all'
|| groupFilter.value !== 'all'
|| relationFilter.value !== 'all'
))
const summaryCards = computed(() => [
{ label: '节点总数', value: String(nodes.value.length) },
{ label: '在线节点', value: String(countOnlineNodes(nodes.value)) },
{ label: '显示中', value: String(countVisibleNodes(nodes.value)) },
{ label: '当前结果', value: String(filteredNodes.value.length) },
{ label: '已勾选', value: String(selectedNodes.value.length) },
])
const batchTargetLabel = computed(() => (
hasSelectedNodes.value
? `当前已选 ${selectedNodes.value.length} 个节点`
: '批量修改仅作用于已勾选节点'
))
function getRouteGroupQuery(): string {
const rawValue = route.query.group
if (Array.isArray(rawValue)) {
@@ -88,6 +125,7 @@ function applyRouteGroupFilter() {
const exists = groups.value.some((group) => String(group.id) === groupValue)
groupFilter.value = exists ? groupValue : 'all'
currentPage.value = 1
}
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
@@ -125,6 +163,34 @@ function openSortEditor() {
sortDialogVisible.value = true
}
function setCurrentPageInRange() {
const totalPages = Math.max(1, Math.ceil(filteredNodes.value.length / pageSize.value))
if (currentPage.value > totalPages) {
currentPage.value = totalPages
}
}
function pruneSelection() {
const validIds = new Set(nodes.value.map((node) => node.id))
selectedNodeIds.value = selectedNodeIds.value.filter((id) => validIds.has(id))
}
function syncTableSelection() {
nextTick(() => {
const table = tableRef.value
if (!table) {
return
}
table.clearSelection()
paginatedNodes.value.forEach((node) => {
if (selectedNodeIds.value.includes(node.id)) {
table.toggleRowSelection(node, true)
}
})
})
}
async function loadNodeBoard() {
loading.value = true
errorMessage.value = ''
@@ -139,7 +205,10 @@ async function loadNodeBoard() {
nodes.value = sortNodesByOrder(nodesResponse.data ?? [])
groups.value = groupsResponse.data ?? []
routes.value = routesResponse.data ?? []
pruneSelection()
applyRouteGroupFilter()
setCurrentPageInRange()
syncTableSelection()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
} finally {
@@ -151,12 +220,67 @@ function handleReset() {
keyword.value = ''
typeFilter.value = 'all'
groupFilter.value = 'all'
relationFilter.value = 'all'
currentPage.value = 1
}
function openNodeGroupManagement() {
void router.push('/node-groups')
}
function handleSelectionChange(selection: AdminNodeItem[]) {
const currentPageIds = paginatedNodes.value.map((item) => item.id)
const selectionIds = selection.map((item) => item.id)
const persistedIds = selectedNodeIds.value.filter((id) => !currentPageIds.includes(id))
selectedNodeIds.value = [...new Set([...persistedIds, ...selectionIds])]
}
function clearSelection() {
selectedNodeIds.value = []
syncTableSelection()
}
function openBatchEditor() {
if (!hasSelectedNodes.value) {
ElMessage.warning('请先勾选需要批量修改的节点')
return
}
batchEditVisible.value = true
}
async function handleBatchSubmit(payload: NodeBatchEditPayload) {
const updatePayload: AdminNodeBatchUpdatePayload = {
ids: [...selectedNodeIds.value],
host: payload.host,
rate: payload.rate,
group_ids: payload.group_ids,
}
try {
await ElMessageBox.confirm(
`确认批量修改 ${selectedNodeIds.value.length} 个节点吗?本次只会更新已启用的字段。`,
'批量修改节点',
{ type: 'warning' },
)
batchSubmitting.value = true
await batchUpdateNodes(updatePayload)
batchEditVisible.value = false
clearSelection()
ElMessage.success(`已批量更新 ${updatePayload.ids.length} 个节点`)
await loadNodeBoard()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '批量修改失败')
} finally {
batchSubmitting.value = false
}
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
@@ -180,12 +304,42 @@ async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
}
}
async function handlePinTop(node: AdminNodeItem) {
const orderedNodes = sortNodesByOrder(nodes.value)
if (orderedNodes[0]?.id === node.id) {
ElMessage.info('当前节点已经在列表顶部')
return
}
markPending(workingIds, node.id, true)
try {
const nextOrder = [node, ...orderedNodes.filter((item) => item.id !== node.id)]
await sortNodes(nextOrder.map((item, index) => ({
id: item.id,
order: index + 1,
})))
currentPage.value = 1
ElMessage.success(`已将“${node.name}”置顶`)
await loadNodeBoard()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点置顶失败')
} finally {
markPending(workingIds, node.id, false)
}
}
async function handleAction(action: NodeAction, node: AdminNodeItem) {
if (action === 'edit') {
openEditEditor(node)
return
}
if (action === 'pin-top') {
await handlePinTop(node)
return
}
markPending(workingIds, node.id, true)
try {
@@ -226,6 +380,32 @@ watch(
applyRouteGroupFilter()
},
)
watch([keyword, typeFilter, groupFilter, relationFilter], () => {
currentPage.value = 1
})
watch(pageSize, () => {
currentPage.value = 1
})
watch(
() => filteredNodes.value.length,
() => {
setCurrentPageInRange()
},
)
watch(
() => [
paginatedNodes.value.map((item) => item.id).join(','),
selectedNodeIds.value.join(','),
],
() => {
syncTableSelection()
},
{ flush: 'post' },
)
</script>
<template>
@@ -235,7 +415,7 @@ watch(
<p class="nodes-kicker">Nodes</p>
<h1>节点管理</h1>
<span>
管理所有节点包括添加筛选显隐控制复制和删除等首批运营动作
现在可以在同一页完成节点筛选分页浏览单行置顶批量修改以及新增编辑显隐和删除等运营动作
</span>
</div>
@@ -285,9 +465,17 @@ watch(
:value="String(group.id)"
/>
</ElSelect>
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
<ElOption label="全部节点" value="all" />
<ElOption label="父节点" value="parent" />
<ElOption label="子节点" value="child" />
</ElSelect>
</div>
<div class="toolbar-actions">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElButton :disabled="!hasSelectedNodes" @click="openBatchEditor">批量修改</ElButton>
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
@@ -297,6 +485,11 @@ watch(
</div>
</header>
<div v-if="hasSelectedNodes" class="selection-summary">
<span class="selection-summary__label">已勾选 {{ selectedNodes.length }} 个节点批量修改只会作用于这些节点</span>
<ElButton text @click="clearSelection">清空勾选</ElButton>
</div>
<ElAlert
v-if="errorMessage"
type="error"
@@ -311,11 +504,14 @@ watch(
</ElAlert>
<ElTable
:data="filteredNodes"
ref="tableRef"
:data="paginatedNodes"
v-loading="loading"
row-key="id"
class="nodes-table"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="52" reserve-selection />
<ElTableColumn label="节点ID" width="132">
<template #default="{ row }">
<ElTag
@@ -420,8 +616,9 @@ watch(
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
<ElDropdownItem command="pin-top">置顶节点</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
</ElDropdownMenu>
@@ -446,10 +643,19 @@ watch(
</ElTable>
<footer class="board-footer">
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
<span> {{ currentPage }} · 已显示 {{ paginatedNodes.length }} / {{ filteredNodes.length }} 个节点</span>
<ElPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
:total="filteredNodes.length"
background
class="footer-pagination"
/>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>节点新增编辑与排序已在当前工作台内接入真实流程</span>
<span>节点新增编辑置顶排序与批量修改已收敛到同一工作台</span>
</div>
</footer>
</section>
@@ -469,6 +675,14 @@ watch(
:nodes="nodes"
@success="() => loadNodeBoard()"
/>
<NodeBatchEditDialog
v-model:visible="batchEditVisible"
:groups="groups"
:selected-count="selectedNodes.length"
:loading="batchSubmitting"
@submit="handleBatchSubmit"
/>
</div>
</template>
@@ -550,7 +764,8 @@ watch(
.toolbar-fields,
.toolbar-actions,
.board-footer,
.footer-hint {
.footer-hint,
.selection-summary {
display: flex;
align-items: center;
gap: 12px;
@@ -578,6 +793,21 @@ watch(
border-radius: 16px;
}
.scope-hint,
.selection-summary__label {
color: var(--xboard-text-muted);
line-height: 1.5;
}
.selection-summary {
justify-content: space-between;
flex-wrap: wrap;
padding: 14px 16px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.nodes-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
@@ -667,6 +897,10 @@ watch(
flex-wrap: wrap;
}
.footer-pagination {
margin-left: auto;
}
.footer-hint {
justify-content: flex-end;
color: var(--xboard-text-muted);
@@ -675,7 +909,8 @@ watch(
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,
.board-footer {
.board-footer,
.selection-summary {
flex-direction: column;
align-items: stretch;
}
@@ -687,6 +922,7 @@ watch(
.toolbar-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}
@@ -20,6 +20,7 @@ interface AssignOrderFormModel {
const props = defineProps<{
visible: boolean
plans: AdminPlanListItem[]
initialEmail?: string
}>()
const emit = defineEmits<{
@@ -52,7 +53,7 @@ const rules = computed<FormRules<AssignOrderFormModel>>(() => ({
}))
function resetForm() {
form.email = ''
form.email = props.initialEmail?.trim() || ''
form.planId = null
form.period = ''
form.totalAmountYuan = null
@@ -5,6 +5,7 @@ import {
COMMISSION_STATUS_UPDATE_OPTIONS,
canCancelOrder,
canMarkOrderPaid,
hasOrderCommission,
canUpdateCommissionStatus,
formatOrderAmount,
formatOrderDateTime,
@@ -34,7 +35,12 @@ const commissionStatusDraft = ref<number | null>(null)
const statusMeta = computed(() => getOrderStatusMeta(props.order?.status))
const typeMeta = computed(() => getOrderTypeMeta(props.order?.type))
const commissionMeta = computed(() => getCommissionStatusMeta(props.order?.commission_status, props.order?.commission_balance))
const commissionMeta = computed(() => getCommissionStatusMeta(
props.order?.commission_status,
props.order?.commission_balance,
props.order?.actual_commission_balance,
))
const hasCommission = computed(() => hasOrderCommission(props.order))
const summaryCards = computed(() => [
{ label: '订单状态', value: statusMeta.value.label, detail: typeMeta.value.label },
@@ -165,7 +171,7 @@ watch(
<header class="card-header">
<div>
<h3>佣金状态</h3>
<p>仅对存在佣金金额的订单开放状态维护</p>
<p>{{ hasCommission ? '仅对真实佣金订单开放状态维护。' : '当前订单未产生佣金,不进入佣金确认或发放流程。' }}</p>
</div>
<span class="hero-badge" :class="`is-${commissionMeta.tone}`">{{ commissionMeta.label }}</span>
</header>
@@ -198,6 +204,10 @@ watch(
保存佣金状态
</ElButton>
</div>
<p v-else class="commission-empty-note">
{{ hasCommission ? '当前佣金状态已完成发放,列表页与详情页均不再提供编辑入口。' : '该订单没有真实佣金,列表页也不会出现在“确认佣金”筛选结果中。' }}
</p>
</section>
<section v-if="props.order.surplus_orders?.length" class="detail-card">
@@ -443,6 +453,12 @@ watch(
gap: 12px;
}
.commission-empty-note {
margin: 0;
color: var(--xboard-text-muted);
line-height: 1.6;
}
.commission-actions :deep(.el-select) {
flex: 1;
}
+16
View File
@@ -72,6 +72,12 @@
color: var(--xboard-text-secondary);
}
.filter-pill--accent {
border-color: rgba(0, 113, 227, 0.16);
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
}
.filter-pill:hover,
.toolbar-ghost:hover {
color: #0071e3;
@@ -86,6 +92,10 @@
margin-bottom: -4px;
}
.orders-alert--info :deep(.el-alert__title) {
color: var(--xboard-text-strong);
}
.orders-table :deep(th.el-table__cell) {
background: #fbfbfd;
color: var(--xboard-text-secondary);
@@ -110,6 +120,12 @@
white-space: nowrap;
}
.action-trigger {
justify-content: center;
min-width: 34px;
color: var(--xboard-text-secondary);
}
.plan-cell {
display: grid;
gap: 4px;
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshRight, Search, TopRight } from '@element-plus/icons-vue'
import { CircleCheck, MoreFilled, Plus, RefreshRight, Search, TopRight } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import {
cancelOrder,
fetchOrders,
@@ -17,6 +18,7 @@ import type {
AdminTableSort,
} from '@/types/api'
import {
canQuickConfirmCommission,
COMMISSION_STATUS_OPTIONS,
ORDER_PERIOD_OPTIONS,
ORDER_STATUS_OPTIONS,
@@ -38,9 +40,14 @@ import {
import OrderAssignDrawer from './OrderAssignDrawer.vue'
import OrderDetailDrawer from './OrderDetailDrawer.vue'
type CommissionWorkbenchMode = 'all' | 'pending' | 'commission'
type OrderRowAction = 'detail' | 'confirm-commission'
const loading = ref(false)
const metaLoading = ref(false)
const errorMessage = ref('')
const route = useRoute()
const router = useRouter()
const orders = ref<AdminOrderListItem[]>([])
const plans = ref<AdminPlanListItem[]>([])
@@ -53,6 +60,7 @@ const typeFilter = ref<OrderFilterValue<number>>('all')
const periodFilter = ref<OrderFilterValue<OrderPeriodKey>>('all')
const statusFilter = ref<OrderFilterValue<number>>('all')
const commissionFilter = ref<OrderFilterValue<number>>('all')
const commissionWorkbench = ref<CommissionWorkbenchMode>('all')
const sortState = ref<AdminTableSort>({ id: 'created_at', desc: true })
const assignVisible = ref(false)
@@ -62,6 +70,7 @@ const detailOrder = ref<AdminOrderDetail | null>(null)
const paying = ref(false)
const cancelling = ref(false)
const updatingCommission = ref(false)
const quickConfirmTradeNo = ref('')
const filterButtonLabels = computed(() => ({
type: typeFilter.value === 'all' ? '类型' : `类型 · ${getOrderFilterLabel(typeFilter.value)}`,
@@ -72,6 +81,54 @@ const filterButtonLabels = computed(() => ({
: `佣金状态 · ${getCommissionStatusFilterLabel(commissionFilter.value)}`,
}))
const scopedUserId = computed(() => {
const raw = route.query.user_id
const value = Array.isArray(raw) ? raw[0] : raw
const numeric = Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
})
const scopedUserEmail = computed(() => {
const raw = route.query.user_email
const value = Array.isArray(raw) ? raw[0] : raw
return typeof value === 'string' ? value : ''
})
const scopedUserFilters = computed(() => (
scopedUserId.value
? [{ id: 'user_id', value: [scopedUserId.value] }]
: []
))
const scopedUserNotice = computed(() => (
scopedUserId.value
? `当前仅展示 ${scopedUserEmail.value || `用户 #${scopedUserId.value}`} 的订单。`
: ''
))
const commissionWorkbenchLabel = computed(() => {
switch (commissionWorkbench.value) {
case 'pending':
return '确认佣金 · 待确认'
case 'commission':
return '确认佣金 · 全部佣金'
default:
return '确认佣金'
}
})
const commissionWorkbenchNotice = computed(() => {
if (commissionWorkbench.value === 'pending') {
return '当前仅展示真实待确认佣金订单,可在操作列直接确认。'
}
if (commissionWorkbench.value === 'commission' || commissionFilter.value !== 'all') {
return '当前仅展示真实佣金订单,佣金状态筛选不会再混入无佣金数据。'
}
return ''
})
async function loadPlans() {
metaLoading.value = true
try {
@@ -91,14 +148,18 @@ async function loadOrders() {
const response = await fetchOrders({
current: current.value,
pageSize: pageSize.value,
filter: buildOrderFetchFilters({
keyword: keyword.value,
type: typeFilter.value,
period: periodFilter.value,
status: statusFilter.value,
commissionStatus: commissionFilter.value,
}),
filter: [
...buildOrderFetchFilters({
keyword: keyword.value,
type: typeFilter.value,
period: periodFilter.value,
status: statusFilter.value,
commissionStatus: commissionFilter.value,
}),
...scopedUserFilters.value,
],
sort: sortState.value ? [sortState.value] : undefined,
is_commission: commissionWorkbench.value !== 'all' || commissionFilter.value !== 'all',
})
orders.value = response.data ?? []
@@ -134,6 +195,26 @@ function handleDropdownSelect(kind: 'type' | 'period' | 'status' | 'commission',
if (kind === 'commission') {
commissionFilter.value = value === 'all' ? 'all' : Number(value)
commissionWorkbench.value = commissionFilter.value === 'all'
? 'all'
: commissionFilter.value === 0
? 'pending'
: 'commission'
}
refreshOrders(true)
}
function handleCommissionWorkbench(command: string) {
if (command === 'pending') {
commissionWorkbench.value = 'pending'
commissionFilter.value = 0
} else if (command === 'commission') {
commissionWorkbench.value = 'commission'
commissionFilter.value = 'all'
} else {
commissionWorkbench.value = 'all'
commissionFilter.value = 'all'
}
refreshOrders(true)
@@ -145,7 +226,15 @@ function clearFilters() {
periodFilter.value = 'all'
statusFilter.value = 'all'
commissionFilter.value = 'all'
commissionWorkbench.value = 'all'
sortState.value = { id: 'created_at', desc: true }
if (scopedUserId.value) {
void router.replace({ name: 'SubscriptionOrders' }).finally(() => {
refreshOrders(true)
})
return
}
refreshOrders(true)
}
@@ -237,6 +326,53 @@ async function handleCommissionStatusUpdate(value: number) {
}
}
function isRowActionWorking(order: Pick<AdminOrderListItem, 'trade_no'>) {
return quickConfirmTradeNo.value === order.trade_no
}
async function handleQuickConfirmCommission(order: AdminOrderListItem) {
if (!canQuickConfirmCommission(order)) {
return
}
try {
await ElMessageBox.confirm(
`确认将订单 ${order.trade_no} 的佣金状态更新为“发放中”吗?`,
'确认佣金',
{ type: 'warning' },
)
quickConfirmTradeNo.value = order.trade_no
await updateOrderCommissionStatus(order.trade_no, 1)
ElMessage.success('佣金已确认,状态已更新为发放中')
const shouldReloadDetail = detailOrder.value?.trade_no === order.trade_no
await Promise.all([
loadOrders(),
shouldReloadDetail ? reloadDetail() : Promise.resolve(),
])
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '佣金确认失败')
} finally {
quickConfirmTradeNo.value = ''
}
}
async function handleRowAction(command: OrderRowAction, order: AdminOrderListItem) {
if (command === 'detail') {
await openDetail(order)
return
}
if (command === 'confirm-commission') {
await handleQuickConfirmCommission(order)
}
}
function handleAssignSuccess() {
assignVisible.value = false
refreshOrders(true)
@@ -260,6 +396,13 @@ watch([current, pageSize], () => {
void loadOrders()
})
watch(
() => [route.query.user_id, route.query.user_email],
() => {
refreshOrders(true)
},
)
onMounted(() => {
void Promise.all([loadPlans(), loadOrders()]).catch(() => {
ElMessage.error('订单管理页面初始化失败')
@@ -372,6 +515,20 @@ onMounted(() => {
</ElDropdownMenu>
</template>
</ElDropdown>
<ElDropdown trigger="click" @command="handleCommissionWorkbench">
<ElButton class="filter-pill filter-pill--accent">
<ElIcon><CircleCheck /></ElIcon>
{{ commissionWorkbenchLabel }}
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="pending">真实待确认订单</ElDropdownItem>
<ElDropdownItem command="commission">全部佣金订单</ElDropdownItem>
<ElDropdownItem command="clear" divided>清空佣金筛选</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div class="toolbar-right">
@@ -398,6 +555,32 @@ onMounted(() => {
</template>
</ElAlert>
<ElAlert
v-if="!errorMessage && scopedUserNotice"
class="orders-alert orders-alert--info"
type="info"
:closable="false"
show-icon
:title="scopedUserNotice"
>
<template #default>
<ElButton size="small" @click="clearFilters">查看全部订单</ElButton>
</template>
</ElAlert>
<ElAlert
v-if="!errorMessage && commissionWorkbenchNotice"
class="orders-alert orders-alert--info"
type="info"
:closable="false"
show-icon
:title="commissionWorkbenchNotice"
>
<template #default>
<ElButton size="small" @click="handleCommissionWorkbench('clear')">清空佣金筛选</ElButton>
</template>
</ElAlert>
<ElTable
:data="orders"
v-loading="loading"
@@ -461,9 +644,9 @@ onMounted(() => {
<ElTableColumn prop="commission_status" label="佣金状态" min-width="150" sortable="custom">
<template #default="{ row }">
<span class="status-pill" :class="`is-${getCommissionStatusMeta(row.commission_status, row.commission_balance).tone}`">
<span class="status-pill" :class="`is-${getCommissionStatusMeta(row.commission_status, row.commission_balance, row.actual_commission_balance).tone}`">
<span class="status-dot" />
{{ getCommissionStatusMeta(row.commission_status, row.commission_balance).label }}
{{ getCommissionStatusMeta(row.commission_status, row.commission_balance, row.actual_commission_balance).label }}
</span>
</template>
</ElTableColumn>
@@ -473,6 +656,28 @@ onMounted(() => {
<span>{{ formatOrderDateTime(row.created_at) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="96" fixed="right">
<template #default="{ row }">
<ElDropdown trigger="click" @command="(command) => handleRowAction(command as OrderRowAction, row)">
<ElButton text class="action-trigger" :loading="isRowActionWorking(row)">
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="detail">查看详情</ElDropdownItem>
<ElDropdownItem
v-if="canQuickConfirmCommission(row)"
command="confirm-commission"
divided
>
确认佣金
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
@@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { AdminPlanOption } from '@/types/api'
import {
cloneUserAdvancedFilters,
createEmptyUserAdvancedFilter,
getUserAdvancedFieldDefinition,
USER_ADVANCED_FIELD_DEFINITIONS,
USER_STATUS_VALUE_OPTIONS,
type UserAdvancedFilterItem,
type UserAdvancedOperator,
} from '@/utils/users'
const props = defineProps<{
visible: boolean
filters: UserAdvancedFilterItem[]
plans: AdminPlanOption[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'apply', value: UserAdvancedFilterItem[]): void
}>()
const draftFilters = ref<UserAdvancedFilterItem[]>([])
const fieldOptions = computed(() => USER_ADVANCED_FIELD_DEFINITIONS.map((item) => ({
label: item.label,
value: item.field,
})))
function resetDraft() {
draftFilters.value = props.filters.length > 0
? cloneUserAdvancedFilters(props.filters)
: [createEmptyUserAdvancedFilter()]
}
function closeDialog() {
emit('update:visible', false)
}
function addCondition() {
draftFilters.value.push(createEmptyUserAdvancedFilter())
}
function removeCondition(index: number) {
if (draftFilters.value.length === 1) {
draftFilters.value = [createEmptyUserAdvancedFilter()]
return
}
draftFilters.value.splice(index, 1)
}
function clearAll() {
draftFilters.value = [createEmptyUserAdvancedFilter()]
}
function getDefinition(field: UserAdvancedFilterItem['field']) {
return getUserAdvancedFieldDefinition(field)
}
function needsValue(operator: UserAdvancedOperator) {
return operator !== 'null' && operator !== 'notnull'
}
function handleFieldChange(filter: UserAdvancedFilterItem) {
const definition = getDefinition(filter.field)
filter.operator = definition.operators[0]?.value ?? 'eq'
filter.value = definition.input === 'number' ? null : ''
}
function handleOperatorChange(filter: UserAdvancedFilterItem) {
if (!needsValue(filter.operator)) {
filter.value = ''
}
}
function applyFilters() {
const cleaned = draftFilters.value
.map((item) => ({
...item,
value: typeof item.value === 'string' ? item.value.trim() : item.value,
}))
.filter((item) => {
if (!needsValue(item.operator)) {
return true
}
return item.value !== '' && item.value !== null && item.value !== undefined
})
emit('apply', cleaned)
}
watch(() => props.visible, (visible) => {
if (visible) {
resetDraft()
}
}, { immediate: true })
</script>
<template>
<ElDialog
:model-value="visible"
width="820px"
destroy-on-close
class="advanced-filter-dialog"
@close="closeDialog"
>
<template #header>
<div class="dialog-header">
<div>
<h2>高级筛选</h2>
<p>添加一个或多个筛选条件来精准查找用户</p>
</div>
<ElButton class="header-button" @click="addCondition">
<ElIcon><Plus /></ElIcon>
添加条件
</ElButton>
</div>
</template>
<div class="dialog-body">
<article v-for="(filter, index) in draftFilters" :key="filter.key" class="condition-card">
<header class="condition-header">
<div class="condition-title">
<strong>条件 {{ index + 1 }}</strong>
<ElSelect
v-if="index > 0"
v-model="filter.logic"
size="small"
class="logic-select"
>
<ElOption label="并且" value="and" />
<ElOption label="或者" value="or" />
</ElSelect>
</div>
<ElButton text class="remove-button" @click="removeCondition(index)">
<ElIcon><Delete /></ElIcon>
</ElButton>
</header>
<div class="condition-grid">
<div class="field-block">
<span>字段</span>
<ElSelect v-model="filter.field" @change="handleFieldChange(filter)">
<ElOption
v-for="option in fieldOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
<div class="field-block">
<span>条件</span>
<ElSelect v-model="filter.operator" @change="handleOperatorChange(filter)">
<ElOption
v-for="option in getDefinition(filter.field).operators"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
<div class="field-block field-block--value">
<span></span>
<template v-if="!needsValue(filter.operator)">
<div class="value-placeholder">当前条件无需填写值</div>
</template>
<ElInput
v-else-if="getDefinition(filter.field).input === 'text'"
v-model="filter.value"
:placeholder="getDefinition(filter.field).placeholder || '请输入筛选值'"
/>
<ElInputNumber
v-else-if="getDefinition(filter.field).input === 'number'"
:model-value="typeof filter.value === 'number' ? filter.value : null"
:min="0"
:step="getDefinition(filter.field).step || 1"
controls-position="right"
class="full-width"
@update:model-value="filter.value = $event ?? null"
/>
<ElSelect
v-else-if="getDefinition(filter.field).input === 'plan'"
v-model="filter.value"
:disabled="!needsValue(filter.operator)"
placeholder="选择订阅计划"
>
<ElOption
v-for="plan in plans"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</ElSelect>
<ElSelect
v-else-if="getDefinition(filter.field).input === 'status'"
v-model="filter.value"
placeholder="选择账号状态"
>
<ElOption
v-for="option in USER_STATUS_VALUE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElDatePicker
v-else
v-model="filter.value"
type="datetime"
value-format="x"
format="YYYY-MM-DD HH:mm"
placeholder="选择时间"
class="full-width"
/>
<small v-if="getDefinition(filter.field).unit && needsValue(filter.operator)">
单位{{ getDefinition(filter.field).unit }}
</small>
</div>
</div>
</article>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton @click="clearAll">清空</ElButton>
<ElButton type="primary" @click="applyFilters">应用筛选</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
.dialog-header,
.condition-header,
.condition-title,
.dialog-footer {
display: flex;
align-items: center;
gap: 12px;
}
.dialog-header,
.condition-header,
.dialog-footer {
justify-content: space-between;
}
.dialog-header h2 {
margin: 0;
font-size: 24px;
color: var(--xboard-text-strong);
}
.dialog-header p {
margin: 8px 0 0;
color: var(--xboard-text-secondary);
}
.header-button {
border-radius: 999px;
}
.dialog-body {
display: grid;
gap: 16px;
}
.condition-card {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fbfbfd;
}
.condition-title strong {
color: var(--xboard-text-strong);
}
.logic-select {
width: 96px;
}
.remove-button {
color: var(--xboard-text-muted);
}
.condition-grid {
display: grid;
grid-template-columns: 1.2fr 1fr 1.6fr;
gap: 14px;
}
.field-block {
display: grid;
gap: 8px;
}
.field-block span {
color: var(--xboard-text-secondary);
font-size: 13px;
}
.field-block small {
color: var(--xboard-text-muted);
font-size: 12px;
}
.field-block--value {
align-self: start;
}
.value-placeholder {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border-radius: 12px;
background: #f1f3f7;
color: var(--xboard-text-muted);
font-size: 13px;
}
.full-width {
width: 100%;
}
@media (max-width: 860px) {
.dialog-header,
.condition-header,
.dialog-footer {
flex-direction: column;
align-items: stretch;
}
.condition-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
const props = defineProps<{
visible: boolean
loading: boolean
targetLabel: string
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'submit', value: { subject: string; content: string }): void
}>()
const form = reactive({
subject: '',
content: '',
})
function resetForm() {
form.subject = ''
form.content = ''
}
function closeDialog() {
emit('update:visible', false)
}
function handleSubmit() {
emit('submit', {
subject: form.subject.trim(),
content: form.content.trim(),
})
}
watch(() => props.visible, (visible) => {
if (visible) {
resetForm()
}
})
</script>
<template>
<ElDialog
:model-value="visible"
width="620px"
destroy-on-close
class="batch-mail-dialog"
@close="closeDialog"
>
<template #header>
<div class="dialog-header">
<h2>发送邮件</h2>
<p>邮件将发送给{{ targetLabel }}</p>
</div>
</template>
<div class="dialog-body">
<ElForm label-position="top">
<ElFormItem label="邮件主题" required>
<ElInput v-model="form.subject" maxlength="120" show-word-limit placeholder="请输入邮件主题" />
</ElFormItem>
<ElFormItem label="邮件内容" required>
<ElInput
v-model="form.content"
type="textarea"
:rows="8"
maxlength="5000"
show-word-limit
placeholder="请输入要发送给用户的内容"
/>
</ElFormItem>
<p class="helper-text">建议在执行前再次确认筛选范围避免误发给不需要通知的用户</p>
</ElForm>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton
type="primary"
:loading="loading"
:disabled="!form.subject.trim() || !form.content.trim()"
@click="handleSubmit"
>
发送邮件
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
.dialog-header h2 {
margin: 0;
font-size: 24px;
color: var(--xboard-text-strong);
}
.dialog-header p {
margin: 8px 0 0;
color: var(--xboard-text-secondary);
}
.dialog-body {
padding-top: 4px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.helper-text {
margin: 0;
color: var(--xboard-text-muted);
font-size: 12px;
line-height: 1.5;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
.users-page {
display: grid;
gap: 24px;
}
.users-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.users-copy {
display: grid;
gap: 10px;
max-width: 680px;
}
.users-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.users-copy h1 {
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.users-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 360px;
}
.hero-stats article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-stats span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-stats strong {
color: #ffffff;
font-size: 22px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-fields,
.toolbar-actions,
.table-footer,
.filter-summary {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-fields {
flex: 1;
flex-wrap: wrap;
}
.toolbar-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.toolbar-input {
width: min(360px, 100%);
}
.toolbar-select {
width: 160px;
}
.filter-pill {
border-radius: 999px;
border-color: var(--xboard-border);
background: #ffffff;
color: var(--xboard-text-secondary);
}
.filter-pill:hover,
.toolbar-ghost:hover {
color: #0071e3;
border-color: rgba(0, 113, 227, 0.18);
}
.filter-pill__count {
min-width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 999px;
background: rgba(0, 113, 227, 0.12);
color: #0071e3;
font-size: 12px;
}
.toolbar-ghost {
color: var(--xboard-text-secondary);
}
.scope-hint {
color: var(--xboard-text-muted);
font-size: 13px;
}
.filter-summary {
flex-wrap: wrap;
padding: 12px 14px;
border-radius: 18px;
background: #f8f8fb;
}
.filter-summary__label {
color: var(--xboard-text-secondary);
font-size: 13px;
font-weight: 600;
}
.filter-summary__tag {
border-color: rgba(0, 113, 227, 0.18);
color: #0071e3;
}
.filter-summary__clear {
color: var(--xboard-text-secondary);
}
.users-alert {
margin-bottom: -4px;
}
.users-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.users-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.email-cell,
.stack-cell,
.traffic-cell {
display: grid;
gap: 6px;
}
.email-cell strong,
.stack-cell strong {
color: var(--xboard-text-strong);
}
.email-cell span,
.stack-cell span,
.table-footer span {
color: var(--xboard-text-muted);
}
.traffic-cell {
min-width: 132px;
}
.action-trigger {
font-size: 18px;
}
@media (max-width: 1180px) {
.users-hero,
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: 1fr;
}
}
+176 -314
View File
@@ -1,160 +1,68 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MoreFilled, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
import { deleteUser, fetchUsers, getPlans, resetUserSecret, updateUser } from '@/api/admin'
import type { AdminPlanOption, AdminUserListItem } from '@/types/api'
import { formatDateTime, formatTraffic } from '@/utils/dashboard'
import { buildUserFilters, getUserStatusMeta, getUserUsagePercent } from '@/utils/users'
import { getUserStatusMeta, getUserUsagePercent } from '@/utils/users'
import OrderAssignDrawer from '@/views/subscriptions/OrderAssignDrawer.vue'
import TrafficLogDialog from '@/views/tickets/TrafficLogDialog.vue'
import UserAdvancedFilterDialog from './UserAdvancedFilterDialog.vue'
import UserBatchMailDialog from './UserBatchMailDialog.vue'
import UserFormDrawer from './UserFormDrawer.vue'
import { useUsersManagement } from './useUsersManagement'
type DrawerMode = 'create' | 'edit'
type UserAction = 'edit' | 'copy' | 'reset-secret' | 'toggle-ban' | 'delete'
type UserAction =
| 'edit'
| 'assign-order'
| 'copy'
| 'reset-secret'
| 'view-orders'
| 'view-invites'
| 'view-traffic'
| 'reset-traffic'
| 'toggle-ban'
| 'delete'
const loading = ref(false)
const plansLoading = ref(false)
const users = ref<AdminUserListItem[]>([])
const plans = ref<AdminPlanOption[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const statusFilter = ref('all')
const planFilter = ref('all')
const drawerVisible = ref(false)
const drawerMode = ref<DrawerMode>('create')
const activeUser = ref<AdminUserListItem | null>(null)
const pageStats = computed(() => [
{ label: '用户总数', value: String(total.value) },
{ label: '当前页', value: String(current.value) },
{ label: '已筛选套餐', value: planFilter.value === 'all' ? '全部' : '单套餐' },
])
async function loadPlans() {
plansLoading.value = true
try {
const response = await getPlans()
plans.value = response.data ?? []
} finally {
plansLoading.value = false
}
}
async function loadUsers() {
loading.value = true
try {
const response = await fetchUsers({
current: current.value,
pageSize: pageSize.value,
filter: buildUserFilters(keyword.value, statusFilter.value, planFilter.value),
sort: [{ id: 'id', desc: true }],
})
users.value = response.data
total.value = response.total
} finally {
loading.value = false
}
}
function openCreateDrawer() {
drawerMode.value = 'create'
activeUser.value = null
drawerVisible.value = true
}
function openEditDrawer(user: AdminUserListItem) {
drawerMode.value = 'edit'
activeUser.value = user
drawerVisible.value = true
}
async function copySubscribeUrl(user: AdminUserListItem) {
if (!navigator.clipboard?.writeText) {
ElMessage.warning('当前环境不支持复制,请手动复制订阅地址')
return
}
await navigator.clipboard.writeText(user.subscribe_url)
ElMessage.success('订阅地址已复制')
}
async function toggleBan(user: AdminUserListItem) {
const nextValue = !user.banned
const actionText = nextValue ? '封禁' : '恢复'
await ElMessageBox.confirm(`确认${actionText}用户 ${user.email} 吗?`, `${actionText}用户`, {
type: 'warning',
})
await updateUser({ id: user.id, banned: nextValue })
ElMessage.success(`用户已${actionText}`)
await loadUsers()
}
async function handleAction(action: UserAction, user: AdminUserListItem) {
if (action === 'edit') {
openEditDrawer(user)
return
}
if (action === 'copy') {
await copySubscribeUrl(user)
return
}
if (action === 'reset-secret') {
await ElMessageBox.confirm(`确认重置 ${user.email} 的 UUID 与订阅地址吗?`, '重置密钥', {
type: 'warning',
})
await resetUserSecret(user.id)
ElMessage.success('UUID 与订阅地址已重置')
await loadUsers()
return
}
if (action === 'toggle-ban') {
await toggleBan(user)
return
}
await ElMessageBox.confirm(`删除用户 ${user.email} 后无法恢复,确认继续吗?`, '删除用户', {
type: 'warning',
})
await deleteUser(user.id)
ElMessage.success('用户已删除')
await loadUsers()
}
function handleSearch() {
current.value = 1
void loadUsers()
}
function handleReset() {
keyword.value = ''
statusFilter.value = 'all'
planFilter.value = 'all'
current.value = 1
void loadUsers()
}
watch(pageSize, () => {
current.value = 1
void loadUsers()
})
watch(current, () => {
void loadUsers()
})
onMounted(() => {
void Promise.all([loadPlans(), loadUsers()]).catch(() => {
ElMessage.error('用户管理页面初始化失败')
})
})
const {
loading,
plansLoading,
errorMessage,
users,
plans,
total,
current,
pageSize,
keyword,
statusFilter,
planFilter,
advancedFilters,
advancedFilterVisible,
batchMailVisible,
batchMailSubmitting,
assignOrderVisible,
assignOrderEmail,
trafficLogVisible,
trafficLogUserId,
trafficLogUserEmail,
drawerVisible,
drawerMode,
activeUser,
selectedUsers,
pageStats,
appliedFilterSummaries,
batchTargetLabel,
batchActionDisabled,
refreshUsers,
handleSearch,
handleReset,
clearAdvancedFilters,
applyAdvancedFilters,
handleSelectionChange,
openCreateDrawer,
handleUserSaved,
handleAction,
handleBatchCommand,
submitBatchMail,
handleAssignOrderSuccess,
} = useUsersManagement()
</script>
<template>
@@ -163,7 +71,7 @@ onMounted(() => {
<div class="users-copy">
<p class="users-kicker">Users</p>
<h1>用户管理工作台</h1>
<span>用一页完成搜索筛选编辑与账户维护保留 Apple 风格的轻量信息层次</span>
<span>现在可以在同一页完成快捷筛选高级筛选行级维护与批量操作继续保持 Apple 风格的轻量运营节奏</span>
</div>
<div class="hero-stats">
@@ -189,7 +97,7 @@ onMounted(() => {
</template>
</ElInput>
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="用户状态">
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="用户状态" @change="handleSearch">
<ElOption label="全部状态" value="all" />
<ElOption label="正常" value="active" />
<ElOption label="封禁" value="banned" />
@@ -200,6 +108,7 @@ onMounted(() => {
class="toolbar-select"
:loading="plansLoading"
placeholder="订阅计划"
@change="handleSearch"
>
<ElOption label="全部订阅" value="all" />
<ElOption
@@ -209,13 +118,43 @@ onMounted(() => {
:value="String(plan.id)"
/>
</ElSelect>
<ElButton class="filter-pill" @click="advancedFilterVisible = true">
高级筛选
<span v-if="appliedFilterSummaries.length" class="filter-pill__count">
{{ appliedFilterSummaries.length }}
</span>
</ElButton>
</div>
<div class="toolbar-actions">
<ElButton @click="handleReset">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElDropdown trigger="click" @command="handleBatchCommand">
<ElButton class="toolbar-ghost" :disabled="batchActionDisabled">
<ElIcon><MoreFilled /></ElIcon>
批量操作
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="send-mail">发送邮件</ElDropdownItem>
<ElDropdownItem command="export-csv">导出 CSV</ElDropdownItem>
<ElDropdownItem command="ban">批量封禁</ElDropdownItem>
<ElDropdownItem command="restore">恢复正常</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton class="toolbar-ghost" @click="handleReset">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton class="toolbar-ghost" :loading="loading" @click="refreshUsers(false)">
<ElIcon><RefreshRight /></ElIcon>
刷新
</ElButton>
<ElButton type="primary" @click="openCreateDrawer">
<ElIcon><Plus /></ElIcon>
创建用户
@@ -223,7 +162,44 @@ onMounted(() => {
</div>
</header>
<ElTable :data="users" v-loading="loading" class="users-table" row-key="id">
<div v-if="appliedFilterSummaries.length" class="filter-summary">
<span class="filter-summary__label">已生效筛选</span>
<ElTag
v-for="item in appliedFilterSummaries"
:key="item"
effect="plain"
round
class="filter-summary__tag"
>
{{ item }}
</ElTag>
<ElButton text class="filter-summary__clear" @click="clearAdvancedFilters">
清空高级筛选
</ElButton>
</div>
<ElAlert
v-if="errorMessage"
class="users-alert"
type="error"
:closable="false"
show-icon
:title="errorMessage"
>
<template #default>
<ElButton size="small" @click="refreshUsers(false)">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
:data="users"
v-loading="loading"
class="users-table"
row-key="id"
empty-text="当前筛选条件下暂无用户"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="52" reserve-selection />
<ElTableColumn prop="id" label="ID" width="92" />
<ElTableColumn label="邮箱" min-width="220">
<template #default="{ row }">
@@ -266,6 +242,14 @@ onMounted(() => {
{{ formatTraffic(row.transfer_enable) }}
</template>
</ElTableColumn>
<ElTableColumn label="在线设备" width="118">
<template #default="{ row }">
<div class="stack-cell">
<strong>{{ row.online_count ?? 0 }}</strong>
<span>当前在线</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="余额" width="118">
<template #default="{ row }">
¥{{ Number(row.balance || 0).toFixed(2) }}
@@ -285,8 +269,13 @@ onMounted(() => {
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑</ElDropdownItem>
<ElDropdownItem command="copy">复制订阅 URL</ElDropdownItem>
<ElDropdownItem command="reset-secret">重置 UUID 及订阅 URL</ElDropdownItem>
<ElDropdownItem command="assign-order">分配订单</ElDropdownItem>
<ElDropdownItem command="copy">复制订阅URL</ElDropdownItem>
<ElDropdownItem command="reset-secret">重置UUID及订阅URL</ElDropdownItem>
<ElDropdownItem command="view-orders">TA的订单</ElDropdownItem>
<ElDropdownItem command="view-invites">TA的邀请</ElDropdownItem>
<ElDropdownItem command="view-traffic">TA的流量记录</ElDropdownItem>
<ElDropdownItem command="reset-traffic">重置流量</ElDropdownItem>
<ElDropdownItem command="toggle-ban">
{{ row.banned ? '恢复正常' : '封禁用户' }}
</ElDropdownItem>
@@ -299,7 +288,7 @@ onMounted(() => {
</ElTable>
<footer class="table-footer">
<span>加载 {{ users.length }} {{ total }} </span>
<span>选择 {{ selectedUsers.length }} {{ total }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
@@ -316,163 +305,36 @@ onMounted(() => {
:mode="drawerMode"
:user="activeUser"
:plans="plans"
@success="() => loadUsers()"
@success="handleUserSaved"
/>
<UserAdvancedFilterDialog
v-model:visible="advancedFilterVisible"
:filters="advancedFilters"
:plans="plans"
@apply="applyAdvancedFilters"
/>
<UserBatchMailDialog
v-model:visible="batchMailVisible"
:loading="batchMailSubmitting"
:target-label="batchTargetLabel"
@submit="submitBatchMail"
/>
<OrderAssignDrawer
v-model:visible="assignOrderVisible"
:plans="plans"
:initial-email="assignOrderEmail"
@success="handleAssignOrderSuccess"
/>
<TrafficLogDialog
v-model:visible="trafficLogVisible"
:user-id="trafficLogUserId"
:user-email="trafficLogUserEmail"
/>
</div>
</template>
<style scoped>
.users-page {
display: grid;
gap: 24px;
}
.users-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.users-copy {
display: grid;
gap: 10px;
max-width: 620px;
}
.users-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.users-copy h1 {
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.users-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 360px;
}
.hero-stats article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-stats span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-stats strong {
color: #ffffff;
font-size: 22px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-fields,
.toolbar-actions,
.table-footer {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-fields {
flex: 1;
flex-wrap: wrap;
}
.toolbar-input {
width: min(360px, 100%);
}
.toolbar-select {
width: 160px;
}
.users-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.users-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.email-cell,
.stack-cell,
.traffic-cell {
display: grid;
gap: 6px;
}
.email-cell strong,
.stack-cell strong {
color: var(--xboard-text-strong);
}
.email-cell span,
.stack-cell span,
.table-footer span {
color: var(--xboard-text-muted);
}
.traffic-cell {
min-width: 132px;
}
.action-trigger {
font-size: 18px;
}
@media (max-width: 1080px) {
.users-hero,
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: 1fr;
}
.toolbar-actions {
justify-content: flex-end;
}
}
</style>
<style scoped lang="scss" src="./UsersView.scss"></style>
@@ -0,0 +1,130 @@
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { resetUserTraffic } from '@/api/admin'
import type { AdminUserFilter, AdminUserListItem } from '@/types/api'
export function useUserScopedActions() {
const route = useRoute()
const router = useRouter()
const assignOrderVisible = ref(false)
const assignOrderEmail = ref('')
const trafficLogVisible = ref(false)
const trafficLogUserId = ref<number | null>(null)
const trafficLogUserEmail = ref('')
const resettingTrafficId = ref<number | null>(null)
const scopedInviteUserId = computed(() => {
const raw = route.query.invite_user_id
const value = Array.isArray(raw) ? raw[0] : raw
const numeric = Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
})
const scopedInviteUserEmail = computed(() => {
const raw = route.query.invite_user_email
const value = Array.isArray(raw) ? raw[0] : raw
return typeof value === 'string' ? value : ''
})
const scopedInviteFilters = computed<AdminUserFilter[]>(() => (
scopedInviteUserId.value
? [{ id: 'invite_user_id', value: `eq:${scopedInviteUserId.value}` }]
: []
))
const scopedInviteSummaries = computed(() => {
if (!scopedInviteUserId.value) {
return []
}
const label = scopedInviteUserEmail.value || `用户 #${scopedInviteUserId.value}`
return [`邀请人:${label}`]
})
function clearScopedInviteQuery() {
if (!scopedInviteUserId.value && !scopedInviteUserEmail.value) {
return Promise.resolve()
}
const nextQuery = { ...route.query }
delete nextQuery.invite_user_id
delete nextQuery.invite_user_email
return router.replace({ name: 'Users', query: nextQuery })
}
function openAssignOrder(user: Pick<AdminUserListItem, 'email'>) {
assignOrderEmail.value = user.email
assignOrderVisible.value = true
}
function handleAssignOrderSuccess() {
assignOrderVisible.value = false
}
function openTrafficLogs(user: Pick<AdminUserListItem, 'id' | 'email'>) {
trafficLogUserId.value = user.id
trafficLogUserEmail.value = user.email
trafficLogVisible.value = true
}
function viewUserOrders(user: Pick<AdminUserListItem, 'id' | 'email'>) {
return router.push({
name: 'SubscriptionOrders',
query: {
user_id: String(user.id),
user_email: user.email,
},
})
}
function viewUserInvites(
user: Pick<AdminUserListItem, 'id' | 'email'>,
resetLocalFilters: () => void,
) {
resetLocalFilters()
return router.push({
name: 'Users',
query: {
invite_user_id: String(user.id),
invite_user_email: user.email,
},
})
}
async function performResetTraffic(user: Pick<AdminUserListItem, 'id' | 'email'>) {
await ElMessageBox.confirm(`确认重置用户 ${user.email} 的已用流量吗?该操作会清空当前上行和下行统计。`, '重置流量', {
type: 'warning',
})
resettingTrafficId.value = user.id
try {
await resetUserTraffic(user.id, '用户管理更多操作手动重置')
ElMessage.success('用户流量已重置')
} finally {
resettingTrafficId.value = null
}
}
return {
route,
assignOrderVisible,
assignOrderEmail,
trafficLogVisible,
trafficLogUserId,
trafficLogUserEmail,
resettingTrafficId,
scopedInviteUserId,
scopedInviteFilters,
scopedInviteSummaries,
clearScopedInviteQuery,
openAssignOrder,
handleAssignOrderSuccess,
openTrafficLogs,
viewUserOrders,
viewUserInvites,
performResetTraffic,
}
}
@@ -0,0 +1,234 @@
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
batchUpdateUserBan,
exportUsersCsv,
sendUsersMail,
} from '@/api/admin'
import type {
AdminUserBulkMailPayload,
AdminUserBulkScopePayload,
AdminUserFilter,
AdminUserListItem,
} from '@/types/api'
import { hasUserFilters, type UserAdvancedFilterItem } from '@/utils/users'
interface UserBatchMailForm {
subject: string
content: string
}
interface UseUsersBatchActionsOptions {
loading: Ref<boolean>
total: Ref<number>
keyword: Ref<string>
statusFilter: Ref<string>
planFilter: Ref<string>
advancedFilters: Ref<UserAdvancedFilterItem[]>
appliedFilters: ComputedRef<AdminUserFilter[]>
loadUsers: () => Promise<void>
}
function isCancelError(error: unknown): boolean {
return error === 'cancel' || error === 'close'
}
function createTimestamp(): string {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
}
function triggerBlobDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
export function useUsersBatchActions(options: UseUsersBatchActionsOptions) {
const selectedUsers = ref<AdminUserListItem[]>([])
const batchMailVisible = ref(false)
const batchMailSubmitting = ref(false)
const batchActionSubmitting = ref(false)
const batchTarget = computed(() => {
if (selectedUsers.value.length > 0) {
return {
scope: 'selected' as const,
label: `当前已选 ${selectedUsers.value.length} 个用户`,
user_ids: selectedUsers.value.map((user) => user.id),
}
}
if (hasUserFilters(
options.keyword.value,
options.statusFilter.value,
options.planFilter.value,
options.advancedFilters.value,
)) {
return {
scope: 'filtered' as const,
label: '当前筛选结果',
filter: options.appliedFilters.value,
}
}
return {
scope: 'all' as const,
label: '全部用户',
}
})
const batchTargetLabel = computed(() => batchTarget.value.label)
const batchActionDisabled = computed(() => (
options.loading.value
|| batchActionSubmitting.value
|| batchMailSubmitting.value
|| (options.total.value === 0 && selectedUsers.value.length === 0)
))
function handleSelectionChange(selection: AdminUserListItem[]) {
selectedUsers.value = selection
}
function resetSelection() {
selectedUsers.value = []
}
function buildScopePayload(): AdminUserBulkScopePayload {
if (batchTarget.value.scope === 'selected') {
return {
scope: 'selected',
user_ids: batchTarget.value.user_ids,
}
}
if (batchTarget.value.scope === 'filtered') {
return {
scope: 'filtered',
filter: batchTarget.value.filter,
}
}
return { scope: 'all' }
}
function buildMutationScopePayload(): Pick<AdminUserBulkMailPayload, 'scope' | 'user_ids' | 'filter' | 'sort' | 'sort_type'> {
return {
...buildScopePayload(),
sort: 'id',
sort_type: 'DESC',
}
}
async function ensureAllUsersConfirmation(actionText: string) {
if (batchTarget.value.scope !== 'all') {
return
}
await ElMessageBox.confirm(`当前未勾选用户且未设置筛选条件,将对全部用户执行“${actionText}”,确认继续吗?`, `确认${actionText}`, {
type: 'warning',
})
}
async function exportCurrentUsers() {
try {
await ensureAllUsersConfirmation('导出 CSV')
batchActionSubmitting.value = true
const blob = await exportUsersCsv(buildScopePayload())
triggerBlobDownload(blob, `users-${batchTarget.value.scope}-${createTimestamp()}.csv`)
ElMessage.success(`CSV 已导出(${batchTarget.value.label}`)
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : 'CSV 导出失败')
}
} finally {
batchActionSubmitting.value = false
}
}
async function submitBatchMail(form: UserBatchMailForm) {
try {
await ensureAllUsersConfirmation('发送邮件')
batchMailSubmitting.value = true
await sendUsersMail({
...buildMutationScopePayload(),
subject: form.subject,
content: form.content,
})
batchMailVisible.value = false
ElMessage.success(`邮件发送任务已提交(${batchTarget.value.label}`)
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : '批量邮件发送失败')
}
} finally {
batchMailSubmitting.value = false
}
}
async function updateBatchBanState(banned: boolean) {
const actionText = banned ? '批量封禁' : '恢复正常'
try {
await ensureAllUsersConfirmation(actionText)
await ElMessageBox.confirm(`确认对${batchTarget.value.label}执行“${actionText}”吗?`, actionText, {
type: 'warning',
})
batchActionSubmitting.value = true
await batchUpdateUserBan({
...buildMutationScopePayload(),
banned: banned ? 1 : 0,
})
ElMessage.success(`${actionText}已完成(${batchTarget.value.label}`)
await options.loadUsers()
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : `${actionText}失败`)
}
} finally {
batchActionSubmitting.value = false
}
}
async function handleBatchCommand(command: string | number | object) {
const normalizedCommand = String(command)
if (normalizedCommand === 'send-mail') {
batchMailVisible.value = true
return
}
if (normalizedCommand === 'export-csv') {
await exportCurrentUsers()
return
}
if (normalizedCommand === 'ban') {
await updateBatchBanState(true)
return
}
if (normalizedCommand === 'restore') {
await updateBatchBanState(false)
}
}
return {
selectedUsers,
batchMailVisible,
batchMailSubmitting,
batchTargetLabel,
batchActionDisabled,
handleSelectionChange,
resetSelection,
handleBatchCommand,
submitBatchMail,
}
}
@@ -0,0 +1,339 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
deleteUser,
fetchUsers,
getPlans,
resetUserSecret,
updateUser,
} from '@/api/admin'
import type {
AdminPlanListItem,
AdminUserFilter,
AdminUserFetchParams,
AdminUserListItem,
} from '@/types/api'
import {
buildUserFilters,
summarizeUserFilters,
type UserAdvancedFilterItem,
} from '@/utils/users'
import { useUsersBatchActions } from './useUsersBatchActions'
import { useUserScopedActions } from './useUserScopedActions'
type DrawerMode = 'create' | 'edit'
type UserAction =
| 'edit'
| 'assign-order'
| 'copy'
| 'reset-secret'
| 'view-orders'
| 'view-invites'
| 'view-traffic'
| 'reset-traffic'
| 'toggle-ban'
| 'delete'
function isCancelError(error: unknown): boolean {
return error === 'cancel' || error === 'close'
}
export function useUsersManagement() {
const loading = ref(false)
const plansLoading = ref(false)
const errorMessage = ref('')
const users = ref<AdminUserListItem[]>([])
const plans = ref<AdminPlanListItem[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const statusFilter = ref('all')
const planFilter = ref('all')
const advancedFilters = ref<UserAdvancedFilterItem[]>([])
const drawerVisible = ref(false)
const drawerMode = ref<DrawerMode>('create')
const activeUser = ref<AdminUserListItem | null>(null)
const advancedFilterVisible = ref(false)
const scopedActions = useUserScopedActions()
const appliedFilters = computed<AdminUserFilter[]>(() => [
...buildUserFilters(
keyword.value,
statusFilter.value,
planFilter.value,
advancedFilters.value,
),
...scopedActions.scopedInviteFilters.value,
])
const appliedFilterSummaries = computed(() => summarizeUserFilters(
keyword.value,
statusFilter.value,
planFilter.value,
advancedFilters.value,
plans.value,
).concat(scopedActions.scopedInviteSummaries.value))
const batchActions = useUsersBatchActions({
loading,
total,
keyword,
statusFilter,
planFilter,
advancedFilters,
appliedFilters,
loadUsers,
})
const pageStats = computed(() => [
{ label: '当前结果', value: String(total.value) },
{ label: '已选用户', value: String(batchActions.selectedUsers.value.length) },
{ label: '生效条件', value: String(appliedFilterSummaries.value.length) },
])
async function loadPlans() {
plansLoading.value = true
try {
const response = await getPlans()
plans.value = response.data ?? []
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '订阅计划加载失败')
} finally {
plansLoading.value = false
}
}
async function loadUsers() {
loading.value = true
errorMessage.value = ''
try {
const params: AdminUserFetchParams = {
current: current.value,
pageSize: pageSize.value,
filter: appliedFilters.value,
sort: [{ id: 'id', desc: true }],
}
const response = await fetchUsers(params)
users.value = response.data ?? []
total.value = response.total ?? 0
batchActions.resetSelection()
} catch (error) {
users.value = []
total.value = 0
errorMessage.value = error instanceof Error ? error.message : '用户列表加载失败'
} finally {
loading.value = false
}
}
function refreshUsers(resetPage: boolean = false) {
if (resetPage && current.value !== 1) {
current.value = 1
return
}
void loadUsers()
}
function handleSearch() {
refreshUsers(true)
}
function handleReset() {
keyword.value = ''
statusFilter.value = 'all'
planFilter.value = 'all'
advancedFilters.value = []
void scopedActions.clearScopedInviteQuery().finally(() => {
refreshUsers(true)
})
}
function clearAdvancedFilters() {
advancedFilters.value = []
refreshUsers(true)
}
function applyAdvancedFilters(filters: UserAdvancedFilterItem[]) {
advancedFilters.value = filters
advancedFilterVisible.value = false
refreshUsers(true)
}
function openCreateDrawer() {
drawerMode.value = 'create'
activeUser.value = null
drawerVisible.value = true
}
function openEditDrawer(user: AdminUserListItem) {
drawerMode.value = 'edit'
activeUser.value = user
drawerVisible.value = true
}
function handleUserSaved() {
refreshUsers(false)
}
async function copySubscribeUrl(user: AdminUserListItem) {
if (!navigator.clipboard?.writeText) {
ElMessage.warning('当前环境不支持复制,请手动复制订阅地址')
return
}
await navigator.clipboard.writeText(user.subscribe_url)
ElMessage.success('订阅地址已复制')
}
async function toggleBan(user: AdminUserListItem) {
const nextValue = !user.banned
const actionText = nextValue ? '封禁' : '恢复'
await ElMessageBox.confirm(`确认${actionText}用户 ${user.email} 吗?`, `${actionText}用户`, {
type: 'warning',
})
await updateUser({ id: user.id, banned: nextValue })
ElMessage.success(`用户已${actionText}`)
await loadUsers()
}
async function handleAction(action: UserAction, user: AdminUserListItem) {
try {
if (action === 'edit') {
openEditDrawer(user)
return
}
if (action === 'assign-order') {
scopedActions.openAssignOrder(user)
return
}
if (action === 'copy') {
await copySubscribeUrl(user)
return
}
if (action === 'reset-secret') {
await ElMessageBox.confirm(`确认重置 ${user.email} 的 UUID 与订阅地址吗?`, '重置密钥', {
type: 'warning',
})
await resetUserSecret(user.id)
ElMessage.success('UUID 与订阅地址已重置')
await loadUsers()
return
}
if (action === 'toggle-ban') {
await toggleBan(user)
return
}
if (action === 'view-orders') {
await scopedActions.viewUserOrders(user)
return
}
if (action === 'view-invites') {
await scopedActions.viewUserInvites(user, () => {
keyword.value = ''
statusFilter.value = 'all'
planFilter.value = 'all'
advancedFilters.value = []
})
return
}
if (action === 'view-traffic') {
scopedActions.openTrafficLogs(user)
return
}
if (action === 'reset-traffic') {
await scopedActions.performResetTraffic(user)
await loadUsers()
return
}
await ElMessageBox.confirm(`删除用户 ${user.email} 后无法恢复,确认继续吗?`, '删除用户', {
type: 'warning',
})
await deleteUser(user.id)
ElMessage.success('用户已删除')
await loadUsers()
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : '用户操作失败')
}
}
}
watch([current, pageSize], () => {
void loadUsers()
})
watch(
() => [
scopedActions.route.query.invite_user_id,
scopedActions.route.query.invite_user_email,
],
() => {
refreshUsers(true)
},
)
onMounted(() => {
void Promise.all([loadPlans(), loadUsers()]).catch(() => {
ElMessage.error('用户管理页面初始化失败')
})
})
return {
loading,
plansLoading,
errorMessage,
users,
plans,
total,
current,
pageSize,
keyword,
statusFilter,
planFilter,
advancedFilters,
advancedFilterVisible,
batchMailVisible: batchActions.batchMailVisible,
batchMailSubmitting: batchActions.batchMailSubmitting,
assignOrderVisible: scopedActions.assignOrderVisible,
assignOrderEmail: scopedActions.assignOrderEmail,
trafficLogVisible: scopedActions.trafficLogVisible,
trafficLogUserId: scopedActions.trafficLogUserId,
trafficLogUserEmail: scopedActions.trafficLogUserEmail,
drawerVisible,
drawerMode,
activeUser,
selectedUsers: batchActions.selectedUsers,
pageStats,
appliedFilterSummaries,
batchTargetLabel: batchActions.batchTargetLabel,
batchActionDisabled: batchActions.batchActionDisabled,
refreshUsers,
handleSearch,
handleReset,
clearAdvancedFilters,
applyAdvancedFilters,
handleSelectionChange: batchActions.handleSelectionChange,
openCreateDrawer,
handleUserSaved,
handleAction,
handleBatchCommand: batchActions.handleBatchCommand,
submitBatchMail: batchActions.submitBatchMail,
handleAssignOrderSuccess: scopedActions.handleAssignOrderSuccess,
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ const uploadTarget = 'https://pic.535888.xyz'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const uploadAuthToken = env.DEV_UPLOAD_AUTH_TOKEN || ''
const buildOutDir = process.env.ADMIN_BUILD_OUT_DIR || env.ADMIN_BUILD_OUT_DIR || '../public/assets/admin'
return {
base: '/assets/admin/',
@@ -51,7 +52,7 @@ export default defineConfig(({ mode }) => {
},
},
build: {
outDir: '../public/assets/admin',
outDir: buildOutDir,
emptyOutDir: true,
},
}
@@ -227,6 +227,10 @@ class ManageController extends Controller
'show' => 'nullable|integer|in:0,1',
'enabled' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
'host' => 'sometimes|required|string',
'rate' => 'sometimes|required|numeric|min:0.01',
'group_ids' => 'sometimes|array',
'group_ids.*' => 'integer',
]);
$ids = $params['ids'];
@@ -244,6 +248,15 @@ class ManageController extends Controller
if (array_key_exists('machine_id', $params)) {
$update['machine_id'] = $params['machine_id'] ?: null;
}
if (array_key_exists('host', $params)) {
$update['host'] = trim((string) $params['host']);
}
if (array_key_exists('rate', $params)) {
$update['rate'] = (float) $params['rate'];
}
if (array_key_exists('group_ids', $params)) {
$update['group_ids'] = $params['group_ids'];
}
if (empty($update)) {
return $this->fail([400, '没有可更新的字段']);
@@ -627,6 +627,9 @@ class UserController extends Controller
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
$banned = in_array((string) $request->input('banned', 1), ['0', '1'], true)
? (int) $request->input('banned', 1)
: 1;
if ($scope === 'selected') {
if (empty($userIds)) {
@@ -649,7 +652,7 @@ class UserController extends Controller
try {
$builder->update([
'banned' => 1
'banned' => $banned
]);
} catch (\Exception $e) {
Log::error($e);