feat(admin-frontend): 新增节点自动上线托管能力
为节点新增 auto_online 字段与后台同步任务, 仅对开启托管的节点按在线状态自动同步前台显示。 管理端补齐单节点与批量开关、列表标识与统计, 并在自动上线启用时禁用手动显隐切换。 后端新增定时命令、保存校验、批量更新支持、 数据库迁移与单元测试,保证托管逻辑可落地。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Vendored
+4
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user