feat(admin-frontend): 新增节点自动上线托管能力

为节点新增 auto_online 字段与后台同步任务,
仅对开启托管的节点按在线状态自动同步前台显示。

管理端补齐单节点与批量开关、列表标识与统计,
并在自动上线启用时禁用手动显隐切换。

后端新增定时命令、保存校验、批量更新支持、
数据库迁移与单元测试,保证托管逻辑可落地。
This commit is contained in:
yinjianm
2026-04-28 00:08:12 +08:00
parent 9af9dd0df7
commit 73b1696b0a
26 changed files with 361 additions and 33 deletions
+14
View File
@@ -1,5 +1,19 @@
# CHANGELOG
## [0.6.2] - 2026-04-27
### 新增
- **[admin-frontend]**: 为节点管理新增可控“自动上线”能力;节点可单独或批量开启后台托管,`sync:server-auto-online` 会按在线状态自动同步前台显示,在线 / 待同步时显示,离线时隐藏,未开启自动上线的节点继续保持手动显隐控制 — by yinjianm
- 方案: [202604272338_admin-frontend-node-auto-online](archive/2026-04/202604272338_admin-frontend-node-auto-online/)
- 决策: admin-frontend-node-auto-online#D001(自动上线使用独立字段与独立同步服务)
## [0.6.1] - 2026-04-27
### 快速修改
- **[admin-frontend]**: 修复独立 admin 前端容器内 `/upload/rest/upload` 返回 404 的问题;`Caddyfile` 现在会把 `/upload/*` 去掉 `/upload` 前缀后反向代理到 `XBOARD_UPLOAD_UPSTREAM`,默认对齐开发环境的图片上传服务 — by yinjianm
- 类型: 快速修改(无方案包)
- 文件: admin-frontend/Caddyfile:1-28
## [0.6.0] - 2026-04-27
### 新增
@@ -0,0 +1,11 @@
{
"status": "completed",
"completed": 9,
"failed": 0,
"pending": 0,
"total": 9,
"done": 9,
"percent": 100,
"current": "节点自动上线功能已实现,前端构建通过,PHP 验证待具备 PHP 的环境补跑",
"updated_at": "2026-04-27 23:52:00"
}
@@ -1,9 +1,11 @@
# 任务清单: admin-frontend-node-auto-online
> **@status:** completed | 2026-04-27 23:56
```yaml
@feature: admin-frontend-node-auto-online
@created: 2026-04-27
@status: in_progress
@status: completed
@mode: R2
@workflow: INTERACTIVE
@complexity: complex
@@ -12,14 +14,14 @@
## LIVE_STATUS
```json
{"status":"in_progress","completed":0,"failed":0,"pending":8,"total":8,"percent":0,"current":"方案包已创建,准备进入开发实施","updated_at":"2026-04-27 23:38:00"}
{"status":"completed","completed":9,"failed":0,"pending":0,"total":9,"percent":100,"current":"节点自动上线功能已实现,前端构建通过,PHP 验证待具备 PHP 的环境补跑","updated_at":"2026-04-27 23:52:00"}
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 0 | 0 | 0 | 8 |
| 9 | 0 | 0 | 9 |
---
@@ -27,25 +29,25 @@
### 1. 后端数据与同步机制
- [ ] 1.1 新增 `database/migrations/*_add_auto_online_to_v2_server_table.php`
- [] 1.1 新增 `database/migrations/*_add_auto_online_to_v2_server_table.php`
- 预期变更: 为 `v2_server` 增加 `auto_online` 布尔字段,默认 `false`down 可回滚字段。
- 完成标准: 迁移文件存在,字段名、默认值和回滚逻辑明确。
- 验证方式: `php -l database/migrations/*_add_auto_online_to_v2_server_table.php`
- depends_on: []
- [ ] 1.2 修改 `app/Models/Server.php`
- [] 1.2 修改 `app/Models/Server.php`
- 预期变更: 增加 `auto_online` 属性注释和 boolean cast,保持现有在线状态访问器与墙状态关系不变。
- 完成标准: `Server` JSON/API 输出包含 `auto_online`,现有 `gfwChecks()` 不被移除。
- 验证方式: `php -l app/Models/Server.php`
- depends_on: [1.1]
- [ ] 1.3 新增 `app/Services/ServerAutoOnlineService.php`
- [] 1.3 新增 `app/Services/ServerAutoOnlineService.php`
- 预期变更: 封装自动上线同步逻辑,只处理 `auto_online=true` 的节点,在线/待同步写 `show=true`,离线写 `show=false`
- 完成标准: 服务返回同步统计,跳过未托管节点,不引入生产外部副作用。
- 验证方式: `php -l app/Services/ServerAutoOnlineService.php`
- depends_on: [1.2]
- [ ] 1.4 新增命令并接入调度 `app/Console/Commands/SyncServerAutoOnline.php`, `app/Console/Kernel.php`
- [] 1.4 新增命令并接入调度 `app/Console/Commands/SyncServerAutoOnline.php`, `app/Console/Kernel.php`
- 预期变更: 新增 `sync:server-auto-online` 命令,每 5 分钟调度,使用 `onOneServer()``withoutOverlapping()`
- 完成标准: 命令可调用服务并输出统计,调度不影响现有任务。
- 验证方式: `php -l app/Console/Commands/SyncServerAutoOnline.php`; `php -l app/Console/Kernel.php`
@@ -53,7 +55,7 @@
### 2. 后端 API 契约
- [ ] 2.1 修改 `app/Http/Requests/Admin/ServerSave.php``app/Http/Controllers/V2/Admin/Server/ManageController.php`
- [] 2.1 修改 `app/Http/Requests/Admin/ServerSave.php``app/Http/Controllers/V2/Admin/Server/ManageController.php`
- 预期变更: `save``update``batchUpdate` 支持 `auto_online`,批量更新保持字段显式传入才更新。
- 完成标准: 手动显隐字段 `show` 和自动上线字段 `auto_online` 可独立保存。
- 验证方式: `php -l app/Http/Requests/Admin/ServerSave.php`; `php -l app/Http/Controllers/V2/Admin/Server/ManageController.php`
@@ -61,13 +63,13 @@
### 3. 管理端前端
- [ ] 3.1 修改 `admin-frontend/src/types/api.d.ts`, `admin-frontend/src/utils/nodeEditorOptions.ts`, `admin-frontend/src/utils/nodeEditorMapper.ts`
- [] 3.1 修改 `admin-frontend/src/types/api.d.ts`, `admin-frontend/src/utils/nodeEditorOptions.ts`, `admin-frontend/src/utils/nodeEditorMapper.ts`
- 预期变更: 类型、表单模型、默认值、编辑回填和保存 payload 支持 `auto_online`
- 完成标准: 新建默认关闭,编辑能正确回填,保存能提交布尔值。
- 验证方式: `npm run build`
- depends_on: [2.1]
- [ ] 3.2 修改 `admin-frontend/src/views/nodes/NodeEditorDialog.vue`, `NodeBatchEditDialog.vue`, `NodesView.vue`, `admin-frontend/src/utils/nodes.ts`
- [] 3.2 修改 `admin-frontend/src/views/nodes/NodeEditorDialog.vue`, `NodeBatchEditDialog.vue`, `NodesView.vue`, `admin-frontend/src/utils/nodes.ts`
- 预期变更: 编辑弹窗和批量修改弹窗增加自动上线开关;节点表格展示自动托管状态;现有墙状态检测 UI 保持可用。
- 完成标准: 管理员可单节点和批量设置 `auto_online`;未开启批量字段时不覆盖。
- 验证方式: `npm run build`
@@ -75,11 +77,17 @@
### 4. 知识库与验收
- [ ] 4.1 `.helloagents/context.md`, `.helloagents/modules/*`, `.helloagents/CHANGELOG.md`
- [] 4.1 新 `tests/Unit/ServerAutoOnlineServiceTest.php`
- 预期变更: 覆盖自动上线服务只同步托管节点、在线显示、离线隐藏、手动节点不受影响。
- 完成标准: 测试用例能表达核心业务边界。
- 验证方式: `php artisan test --filter=ServerAutoOnlineServiceTest`
- depends_on: [1.3]
- [√] 4.2 更新 `.helloagents/context.md`, `.helloagents/modules/*`, `.helloagents/CHANGELOG.md`
- 预期变更: 同步记录节点自动上线能力、后端命令和管理端节点页行为。
- 完成标准: 知识库反映代码事实,CHANGELOG 包含方案链接和决策 ID。
- 验证方式: 人工检查文档条目与本次改动一致。
- depends_on: [1.4, 2.1, 3.2]
- depends_on: [1.4, 2.1, 3.2, 4.1]
---
@@ -88,6 +96,10 @@
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-27 23:38 | DESIGN | in_progress | 用户选择方案 A,方案包创建并进入开发实施 |
| 2026-04-27 23:45 | DEVELOP | in_progress | 完成后端 auto_online 字段、服务、命令和 API 扩展 |
| 2026-04-27 23:48 | DEVELOP | in_progress | 完成管理端节点表格、编辑弹窗与批量修改自动上线开关 |
| 2026-04-27 23:50 | VERIFY | warning | `npm run build` 通过;当前环境缺少 php,PHP 语法检查和 PHPUnit 未执行 |
| 2026-04-27 23:52 | KB | completed | context、admin-frontend 模块索引和 CHANGELOG 已同步 |
---
@@ -95,3 +107,4 @@
- 当前工作树已有节点墙状态检测相关未提交改动,本任务必须保留并兼容这些改动。
- 按上级工具约束,本轮不调度子代理,复杂任务由主代理直接实施并在验收中说明。
- PHP 命令在当前环境不可用,需在具备 PHP/Composer 的环境补跑 `php -l``php artisan test --filter=ServerAutoOnlineServiceTest`
+1
View File
@@ -7,6 +7,7 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
| 202604272338 | admin-frontend-node-auto-online | - | - | - | ✅完成 |
| 202604272325 | node-gfw-check | implementation | node-gfw-check,admin-frontend,mi-node | node-gfw-check#D001,#D002 | ✅完成 |
| 202604272310 | ticket-chat-image-dnd-paste-upload | implementation | admin-frontend | ticket-chat-image-dnd-paste-upload#D001 | ✅完成 |
| 202604250018 | admin-frontend-user-activity-status-filter | implementation | admin-frontend,backend | admin-frontend-user-activity-status-filter#D001,#D002,#D003 | ✅完成 |
+7 -1
View File
@@ -12,6 +12,7 @@
- `admin-frontend` 现支持通过 `ADMIN_BUILD_OUT_DIR` 覆写构建输出目录:仓内默认仍写到 `../public/assets/admin`,容器构建可切到独立 `dist`
- 前端容器化运行采用 `admin-frontend/Dockerfile``Node 20 + Caddy` 多阶段构建),静态站点入口重定向到 `/assets/admin/`
- 前端容器会通过 `XBOARD_BACKEND_UPSTREAM``/api` 反向代理到后端 `web` 服务;compose 分支当前默认值为 `http://web:7001`
- 前端容器会通过 `XBOARD_UPLOAD_UPSTREAM``/upload/*` 去掉 `/upload` 前缀后反向代理到图片上传服务,默认值为 `https://pic.535888.xyz`
- 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`
@@ -47,6 +48,10 @@
- `server/manage/update`
- `server/manage/copy`
- `server/manage/drop`
- `server/manage/batchDelete`
- `server/manage/checkGfw`
- `server/manage/resetTraffic`
- `server/manage/batchResetTraffic`
- 管理端套餐管理现已接入:
- `plan/fetch`
- `plan/save`
@@ -106,7 +111,8 @@
- 管理端路由使用 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 种协议的新增 / 编辑弹窗和排序对话框
- 节点自动上线由后端 `sync:server-auto-online` 定时命令执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;未开启自动上线的节点继续保持手动显隐控制
- Bearer Token 存储于 `sessionStorage/localStorage`
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
+1 -1
View File
@@ -2,7 +2,7 @@
| 模块名 | 说明 | 最近更新 |
|--------|------|----------|
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-25 |
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-27 |
| [node-gfw-check](node-gfw-check.md) | 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 | 2026-04-27 |
| [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 |
| [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 |
+4 -2
View File
@@ -12,6 +12,7 @@
- 默认构建输出仍为主仓 `public/assets/admin`;当 `ADMIN_BUILD_OUT_DIR` 存在时,构建输出需切换到外部指定目录,供容器镜像独立打包
- 独立容器运行时通过 `Caddyfile` 把根路径重定向到 `/assets/admin/`,避免当前 `base: '/assets/admin/'` 资源前缀失效
- 独立容器运行时会把 `/api` 反向代理到 `XBOARD_BACKEND_UPSTREAM`compose 分支默认指向 `http://web:7001`,确保前端静态容器仍能直连 Laravel 后端
- 独立容器运行时会把 `/upload/*` 去掉 `/upload` 前缀后反向代理到 `XBOARD_UPLOAD_UPSTREAM`,默认指向 `https://pic.535888.xyz`,保持与 Vite 开发代理一致的图片上传路径
- 前端 GHCR 发布链路与 Laravel 主应用发布链路分离,避免把静态前端构建耦合进现有 PHP 镜像工作流
- 登录成功后优先跳转 `redirect` 指定路由,否则回到 `/dashboard`
- 受保护路由在未登录时会自动附加 `redirect` 查询参数
@@ -40,7 +41,8 @@
- 节点新增 / 编辑 / 批量修改保存 `group_ids / route_ids` 时统一向后端提交字符串 ID,后端 `Server::whereGroupId()` 同时兼容历史字符串与数字 JSON 值,避免权限组保存后订阅侧无法命中节点
- TUIC 表单默认以 V5 / V4 版本选择、`h3 / h2 / http/1.1` ALPN 选项和 `native / quic` UDP Relay Mode 对齐后端协议模板;AnyTLS Padding Scheme 默认值与 `Server` 模型完整模板保持一致
- 节点排序采用本地草稿 + 上移 / 下移模式,保存时向 `server/manage/sort` 提交 `{ id, order }[]` 顺序 payload
- 节点列表现支持本地分页、在线 / 离线筛选、父/子节点筛选,以及跨分页稳定勾选;批量修改 / 批量删除仅作用于已勾选节点,其中批量修改可统一更新 `host / group_ids / rate`
- 节点列表现支持本地分页、在线 / 离线筛选、父/子节点筛选,以及跨分页稳定勾选;批量修改 / 批量删除仅作用于已勾选节点,其中批量修改可统一更新 `host / group_ids / rate / auto_online`
- 节点管理页新增“自动上线”托管开关;开启后后台 `sync:server-auto-online` 会按节点在线状态自动同步 `show`,在线 / 待同步时显示、离线时隐藏,未开启的节点仍保持手动显隐控制
- 节点管理页现支持墙状态展示、墙状态筛选与关键词搜索;父节点可通过行级或批量操作发起检测,子节点不单独检测并显示“随父节点”的继承状态
- 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 payload 并提交到 `server/manage/sort`
- 权限组管理页使用真实后端 `server/group/fetch``server/group/save``server/group/drop`,支持关键字搜索、新增/编辑中央弹窗、删除确认,以及从节点数量列跳转到 `#/nodes?group={id}` 的筛选联动
@@ -96,7 +98,7 @@
- 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化
- 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化
- 依赖 `src/utils/routes.ts` 负责路由动作映射、匹配规则序列化、节点引用摘要与搜索过滤
- 依赖 `src/utils/nodes.ts` 负责节点在线状态、父/子节点、墙状态 meta、搜索文本和筛选逻辑
- 依赖 `src/utils/nodes.ts` 负责节点在线状态、自动上线统计、父/子节点、墙状态 meta、搜索文本和筛选逻辑
- 依赖 `src/views/tickets/useTicketReplyImages.ts` 收敛工单回复区图片点击上传、拖拽上传、粘贴上传、文件校验和 Markdown 插入
- 依赖 Laravel 后端 `TicketService::reply()` 提供工单“再次回复自动重开”的统一业务语义
- 依赖 Laravel 注入的 `window.settings`
@@ -1,11 +0,0 @@
{
"status": "in_progress",
"completed": 0,
"failed": 0,
"pending": 8,
"total": 8,
"done": 0,
"percent": 0,
"current": "方案包已创建,准备进入开发实施",
"updated_at": "2026-04-27 23:38:00"
}
+8
View File
@@ -7,6 +7,14 @@
reverse_proxy {$XBOARD_BACKEND_UPSTREAM:http://web:7001}
}
@upload path /upload /upload/*
handle @upload {
uri strip_prefix /upload
reverse_proxy {$XBOARD_UPLOAD_UPSTREAM:https://pic.535888.xyz} {
header_up Host {upstream_hostport}
}
}
redir / /assets/admin/ 308
redir /assets/admin /assets/admin/ 308
+4
View File
@@ -880,6 +880,7 @@ export interface AdminNodeItem {
route_ids?: Array<number | string> | null
tags?: string[] | null
show: boolean
auto_online?: boolean
enabled?: boolean
parent_id?: number | null
rate?: number | null
@@ -939,6 +940,7 @@ export interface AdminNodeGfwCheckResult {
export interface AdminNodeUpdatePayload {
id: number
show?: boolean | number
auto_online?: boolean
enabled?: boolean
machine_id?: number | null
}
@@ -948,6 +950,7 @@ export interface AdminNodeBatchUpdatePayload {
host?: string
rate?: number
group_ids?: string[]
auto_online?: boolean
}
export interface AdminNodeSavePayload {
@@ -968,6 +971,7 @@ export interface AdminNodeSavePayload {
rate_time_ranges?: AdminNodeRateTimeRange[]
protocol_settings?: Record<string, unknown>
show?: boolean | number
auto_online?: boolean
}
declare global {
@@ -171,6 +171,7 @@ export function toNodeFormModel(node?: AdminNodeItem | null): NodeFormModel {
form.serverPort = toStringValue(node.server_port)
form.parentId = node.parent_id ?? null
form.show = toBooleanValue(node.show, true)
form.autoOnline = toBooleanValue(node.auto_online)
form.enabled = toBooleanValue(node.enabled, true)
form.tlsMode = Number(protocolSettings.tls ?? 0)
form.tlsServerName = toStringValue(tlsSettings.server_name || tlsObject.server_name)
@@ -491,5 +492,6 @@ export function toNodeSavePayload(form: NodeFormModel): AdminNodeSavePayload {
rate_time_ranges: form.rateTimeEnable ? buildRateRanges(form) : [],
protocol_settings: buildProtocolSettings(form),
show: form.show ? 1 : 0,
auto_online: form.autoOnline,
}
}
@@ -31,6 +31,7 @@ export interface NodeFormModel {
serverPort: string
parentId: number | null
show: boolean
autoOnline: boolean
enabled: boolean
tlsMode: number
tlsServerName: string
@@ -241,6 +242,7 @@ export function createEmptyNodeForm(): NodeFormModel {
serverPort: '',
parentId: null,
show: true,
autoOnline: false,
enabled: true,
tlsMode: 0,
tlsServerName: '',
+5
View File
@@ -199,6 +199,7 @@ function buildNodeSearchText(node: AdminNodeItem): string {
node.port,
node.server_port,
getNodeTypeLabel(node.type),
node.auto_online ? '自动上线 自动托管 auto online' : '',
getNodeGfwMeta(node).searchText,
...getNodeGroupNames(node),
]
@@ -277,3 +278,7 @@ export function countOnlineNodes(nodes: AdminNodeItem[]): number {
export function countVisibleNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => Boolean(node.show)).length
}
export function countAutoOnlineNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => Boolean(node.auto_online)).length
}
@@ -50,6 +50,12 @@
align-items: flex-start;
}
.batch-switch-card--nested {
padding: 12px 14px;
border-radius: 16px;
background: #ffffff;
}
.batch-switch-card strong {
display: block;
margin-bottom: 4px;
@@ -7,6 +7,7 @@ interface NodeBatchEditPayload {
host?: string
rate?: number
group_ids?: string[]
auto_online?: boolean
}
const props = defineProps<{
@@ -28,9 +29,16 @@ const form = reactive({
rate: 1,
updateGroups: false,
groupIds: [] as number[],
updateAutoOnline: false,
autoOnline: true,
})
const hasEnabledField = computed(() => form.updateHost || form.updateRate || form.updateGroups)
const hasEnabledField = computed(() => (
form.updateHost
|| form.updateRate
|| form.updateGroups
|| form.updateAutoOnline
))
function resetForm() {
form.updateHost = false
@@ -39,6 +47,8 @@ function resetForm() {
form.rate = 1
form.updateGroups = false
form.groupIds = []
form.updateAutoOnline = false
form.autoOnline = true
}
function closeDialog() {
@@ -65,6 +75,7 @@ function handleSubmit() {
host: form.updateHost ? form.host.trim() : undefined,
rate: form.updateRate ? Number(form.rate) : undefined,
group_ids: form.updateGroups ? [...new Set(form.groupIds.map((item) => String(item)))] : undefined,
auto_online: form.updateAutoOnline ? form.autoOnline : undefined,
})
}
@@ -159,11 +170,29 @@ watch(
class="full-width"
/>
</section>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量设置自动上线</strong>
<span>启用后会统一设置所选节点是否由后台自动同步前台显示</span>
</div>
<ElSwitch v-model="form.updateAutoOnline" />
</label>
<label class="batch-switch-card batch-switch-card--nested">
<div>
<strong>{{ form.autoOnline ? '开启自动上线' : '关闭自动上线' }}</strong>
<span>关闭时节点显隐继续由管理员手动控制</span>
</div>
<ElSwitch v-model="form.autoOnline" :disabled="!form.updateAutoOnline" />
</label>
</section>
</div>
<template #footer>
<div class="batch-footer">
<span class="batch-footer__hint">批量修改不会影响端口协议配置与显隐状态</span>
<span class="batch-footer__hint">未开启的批量字段不会被提交自动上线不会改动端口与协议配置</span>
<div class="batch-footer__actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="props.loading" @click="handleSubmit">
@@ -327,9 +327,16 @@ watch(
<label class="switch-card">
<div>
<strong>前台显示</strong>
<span>开启后节点会出现在可展示列表中</span>
<span>{{ form.autoOnline ? '已由自动上线托管,后台会按在线状态同步。' : '开启后节点会出现在可展示列表中。' }}</span>
</div>
<ElSwitch v-model="form.show" />
<ElSwitch v-model="form.show" :disabled="form.autoOnline" />
</label>
<label class="switch-card">
<div>
<strong>自动上线</strong>
<span>开启后后台会自动同步显示状态在线显示离线隐藏</span>
</div>
<ElSwitch v-model="form.autoOnline" />
</label>
<label class="switch-card">
<div>
+59 -1
View File
@@ -35,6 +35,7 @@ import NodeEditorDialog from './NodeEditorDialog.vue'
import NodeSortDialog from './NodeSortDialog.vue'
import {
buildNodeTypeOptions,
countAutoOnlineNodes,
countOnlineNodes,
countVisibleNodes,
filterNodes,
@@ -75,6 +76,7 @@ const pageSize = ref(20)
const selectedNodeIds = ref<number[]>([])
const syncingSelection = ref(false)
const switchingIds = ref<number[]>([])
const autoSwitchingIds = ref<number[]>([])
const workingIds = ref<number[]>([])
const editorVisible = ref(false)
const editorMode = ref<NodeDialogMode>('create')
@@ -116,6 +118,7 @@ const summaryCards = computed(() => [
{ label: '节点总数', value: String(nodes.value.length) },
{ label: '在线节点', value: String(countOnlineNodes(nodes.value)) },
{ label: '显示中', value: String(countVisibleNodes(nodes.value)) },
{ label: '自动上线', value: String(countAutoOnlineNodes(nodes.value)) },
{ label: '已勾选', value: String(selectedNodes.value.length) },
])
@@ -159,6 +162,10 @@ function isSwitching(id: number): boolean {
return switchingIds.value.includes(id)
}
function isAutoSwitching(id: number): boolean {
return autoSwitchingIds.value.includes(id)
}
function isWorking(id: number): boolean {
return workingIds.value.includes(id)
}
@@ -285,6 +292,7 @@ async function handleBatchSubmit(payload: NodeBatchEditPayload) {
host: payload.host,
rate: payload.rate,
group_ids: payload.group_ids,
auto_online: payload.auto_online,
}
try {
@@ -422,6 +430,29 @@ async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
}
}
async function handleToggleAutoOnline(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.auto_online)
if (previous === nextValue) {
return
}
node.auto_online = nextValue
markPending(autoSwitchingIds, node.id, true)
try {
await updateNode({
id: node.id,
auto_online: nextValue,
})
ElMessage.success(nextValue ? '已开启自动上线' : '已关闭自动上线')
} catch (error) {
node.auto_online = previous
ElMessage.error(error instanceof Error ? error.message : '自动上线状态更新失败')
} finally {
markPending(autoSwitchingIds, node.id, false)
}
}
async function handlePinTop(node: AdminNodeItem) {
const orderedNodes = sortNodesByOrder(nodes.value)
if (orderedNodes[0]?.id === node.id) {
@@ -689,12 +720,25 @@ watch(
<ElSwitch
:model-value="Boolean(row.show)"
:loading="isSwitching(row.id)"
:disabled="Boolean(row.auto_online)"
@change="(value) => handleToggleShow(row, Boolean(value))"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="自动上线" width="118">
<template #default="{ row }">
<div class="switch-shell switch-shell--auto">
<ElSwitch
:model-value="Boolean(row.auto_online)"
:loading="isAutoSwitching(row.id)"
@change="(value) => handleToggleAutoOnline(row, Boolean(value))"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="节点" min-width="280">
<template #default="{ row }">
<div class="node-cell">
@@ -706,6 +750,15 @@ watch(
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
{{ getNodeStatusMeta(row).label }}
</ElTag>
<ElTag
v-if="row.auto_online"
round
effect="plain"
type="primary"
class="auto-online-tag"
>
自动上线
</ElTag>
<ElTooltip :content="getNodeGfwTooltip(row)" placement="top">
<ElTag
round
@@ -991,6 +1044,10 @@ watch(
--el-switch-on-color: var(--node-switch-color);
}
.switch-shell--auto :deep(.el-switch) {
--el-switch-on-color: #0071e3;
}
.node-cell,
.stack-cell,
.online-cell {
@@ -1055,7 +1112,8 @@ watch(
.rate-tag,
.id-tag,
.gfw-tag {
.gfw-tag,
.auto-online-tag {
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Services\ServerAutoOnlineService;
use Illuminate\Console\Command;
class SyncServerAutoOnline extends Command
{
protected $signature = 'sync:server-auto-online';
protected $description = 'Sync visible status for nodes with auto online enabled';
public function handle(ServerAutoOnlineService $service): int
{
$result = $service->sync();
$this->info(sprintf(
'Server auto online synced: total=%d updated=%d shown=%d hidden=%d unchanged=%d',
$result['total'],
$result['updated'],
$result['shown'],
$result['hidden'],
$result['unchanged']
));
return self::SUCCESS;
}
}
+1
View File
@@ -44,6 +44,7 @@ class Kernel extends ConsoleKernel
$schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer();
// cleanup stale online_count (GC for Redis TTL expiration)
$schedule->command('cleanup:online-status')->everyFiveMinutes()->onOneServer();
$schedule->command('sync:server-auto-online')->everyFiveMinutes()->onOneServer()->withoutOverlapping(5);
// backup Timing
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
@@ -81,6 +81,7 @@ class ManageController extends Controller
$params = $request->validate([
'id' => 'required|integer',
'show' => 'nullable|integer',
'auto_online' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
'enabled' => 'nullable|boolean',
]);
@@ -93,6 +94,9 @@ class ManageController extends Controller
if (array_key_exists('show', $params)) {
$server->show = (int) $params['show'];
}
if (array_key_exists('auto_online', $params)) {
$server->auto_online = (bool) $params['auto_online'];
}
if (array_key_exists('machine_id', $params)) {
$server->machine_id = $params['machine_id'] ?: null;
}
@@ -226,6 +230,7 @@ class ManageController extends Controller
'ids' => 'required|array',
'ids.*' => 'integer',
'show' => 'nullable|integer|in:0,1',
'auto_online' => 'nullable|boolean',
'enabled' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
'host' => 'sometimes|required|string',
@@ -243,6 +248,9 @@ class ManageController extends Controller
if (array_key_exists('show', $params) && $params['show'] !== null) {
$update['show'] = (int) $params['show'];
}
if (array_key_exists('auto_online', $params) && $params['auto_online'] !== null) {
$update['auto_online'] = (bool) $params['auto_online'];
}
if (array_key_exists('enabled', $params) && $params['enabled'] !== null) {
$update['enabled'] = (bool) $params['enabled'];
}
+1
View File
@@ -116,6 +116,7 @@ class ServerSave extends FormRequest
'spectific_key' => 'nullable|string',
'code' => 'nullable|string',
'show' => '',
'auto_online' => 'nullable|boolean',
'name' => 'required|string',
'group_ids' => 'nullable|array',
'route_ids' => 'nullable|array',
+3 -1
View File
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
* @property array|null $route_ids 路由IDs
* @property array|null $tags 标签
* @property boolean $show 是否显示
* @property boolean $auto_online 是否根据在线状态自动同步显示
* @property string|null $allow_insecure 是否允许不安全
* @property string|null $network 网络类型
* @property int|null $parent_id 父节点ID
@@ -36,7 +37,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
* @property int $updated_at
*
* @property-read Server|null $parent 父节点
* @property-read \Illuminate\Database\Eloquent\Collection<int, StatServer> $stats 节点统计
* @property-read \Illuminate\Database\Eloquent\Collection<int, StatServer> $stats 节点统计
* @property-read \Illuminate\Database\Eloquent\Collection<int, ServerGfwCheck> $gfwChecks 墙状态检测记录
*
* @property-read int|null $last_check_at 最后检查时间(Unix时间戳)
@@ -125,6 +126,7 @@ class Server extends Model
'last_check_at' => 'integer',
'last_push_at' => 'integer',
'show' => 'boolean',
'auto_online' => 'boolean',
'enabled' => 'boolean',
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Models\Server;
class ServerAutoOnlineService
{
public function sync(): array
{
$servers = Server::query()
->where('auto_online', true)
->get();
$result = [
'total' => $servers->count(),
'updated' => 0,
'shown' => 0,
'hidden' => 0,
'unchanged' => 0,
];
foreach ($servers as $server) {
$shouldShow = (int) $server->available_status !== Server::STATUS_OFFLINE;
if ((bool) $server->show === $shouldShow) {
$result['unchanged']++;
continue;
}
$server->show = $shouldShow;
$server->save();
$result['updated']++;
$shouldShow ? $result['shown']++ : $result['hidden']++;
}
return $result;
}
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('v2_server', function (Blueprint $table) {
if (!Schema::hasColumn('v2_server', 'auto_online')) {
$table->boolean('auto_online')
->default(false)
->after('show')
->comment('Automatically sync show status from node online state');
}
});
}
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
if (Schema::hasColumn('v2_server', 'auto_online')) {
$table->dropColumn('auto_online');
}
});
}
};
@@ -0,0 +1,62 @@
<?php
namespace Tests\Unit;
use App\Models\Server;
use App\Services\ServerAutoOnlineService;
use App\Services\ServerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ServerAutoOnlineServiceTest extends TestCase
{
use RefreshDatabase;
public function test_sync_updates_only_nodes_with_auto_online_enabled(): void
{
$managedOffline = $this->makeServer([
'name' => 'managed-offline',
'show' => true,
'auto_online' => true,
]);
$managedOnline = $this->makeServer([
'name' => 'managed-online',
'show' => false,
'auto_online' => true,
]);
$manualOffline = $this->makeServer([
'name' => 'manual-offline',
'show' => true,
'auto_online' => false,
]);
ServerService::touchNode($managedOnline);
$result = app(ServerAutoOnlineService::class)->sync();
$this->assertSame(2, $result['total']);
$this->assertSame(2, $result['updated']);
$this->assertSame(1, $result['shown']);
$this->assertSame(1, $result['hidden']);
$this->assertFalse($managedOffline->fresh()->show);
$this->assertTrue($managedOnline->fresh()->show);
$this->assertTrue($manualOffline->fresh()->show);
}
private function makeServer(array $attributes = []): Server
{
return Server::create(array_merge([
'name' => 'test-node',
'type' => Server::TYPE_VMESS,
'host' => '127.0.0.1',
'port' => 443,
'server_port' => 443,
'rate' => '1',
'group_ids' => [1],
'show' => false,
'auto_online' => false,
'enabled' => true,
], $attributes));
}
}