Files
Xboard/admin-frontend/src/views/nodes/NodesView.vue
T
yinjianm 73b1696b0a feat(admin-frontend): 新增节点自动上线托管能力
为节点新增 auto_online 字段与后台同步任务,
仅对开启托管的节点按在线状态自动同步前台显示。

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

后端新增定时命令、保存校验、批量更新支持、
数据库迁移与单元测试,保证托管逻辑可落地。
2026-04-28 00:08:12 +08:00

1175 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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,
Delete,
MoreFilled,
Plus,
RefreshRight,
Search,
User,
} from '@element-plus/icons-vue'
import {
batchDeleteNodes,
batchUpdateNodes,
checkNodeGfw,
copyNode,
deleteNode,
fetchNodes,
fetchNodeRoutes,
getServerGroups,
sortNodes,
updateNode,
} from '@/api/admin'
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 {
buildNodeTypeOptions,
countAutoOnlineNodes,
countOnlineNodes,
countVisibleNodes,
filterNodes,
formatNodeRate,
getNodeGfwMeta,
getNodeGfwTooltip,
getNodeAddress,
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
getNodeTypeLabel,
type NodeRelationFilter,
type NodeGfwFilter,
type NodeStatusFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete' | 'check-gfw'
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[]>([])
const groups = ref<AdminServerGroupItem[]>([])
const routes = ref<AdminNodeRouteItem[]>([])
const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const statusFilter = ref<NodeStatusFilter>('all')
const relationFilter = ref<NodeRelationFilter>('all')
const gfwFilter = ref<NodeGfwFilter>('all')
const currentPage = ref(1)
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')
const activeNode = ref<AdminNodeItem | null>(null)
const sortDialogVisible = ref(false)
const batchEditVisible = ref(false)
const batchSubmitting = ref(false)
const batchDeleting = ref(false)
const batchGfwChecking = ref(false)
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
nodes.value,
keyword.value,
typeFilter.value,
groupFilter.value,
statusFilter.value,
relationFilter.value,
gfwFilter.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 hasSelectedNodes = computed(() => selectedNodes.value.length > 0)
const hasActiveFilters = computed(() => (
keyword.value !== ''
|| typeFilter.value !== 'all'
|| groupFilter.value !== 'all'
|| statusFilter.value !== 'all'
|| relationFilter.value !== 'all'
|| gfwFilter.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(countAutoOnlineNodes(nodes.value)) },
{ 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)) {
return String(rawValue[0] ?? '')
}
return String(rawValue ?? '')
}
function applyRouteGroupFilter() {
const groupValue = getRouteGroupQuery().trim()
if (!groupValue) {
return
}
const exists = groups.value.some((group) => String(group.id) === groupValue)
groupFilter.value = exists ? groupValue : 'all'
currentPage.value = 1
}
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
if (pending) {
if (!list.value.includes(id)) {
list.value = [...list.value, id]
}
return
}
list.value = list.value.filter((item) => item !== id)
}
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)
}
function openCreateEditor() {
editorMode.value = 'create'
activeNode.value = null
editorVisible.value = true
}
function openEditEditor(node: AdminNodeItem) {
editorMode.value = 'edit'
activeNode.value = node
editorVisible.value = true
}
function openSortEditor() {
sortDialogVisible.value = true
}
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
}
syncingSelection.value = true
try {
table.clearSelection()
paginatedNodes.value.forEach((node) => {
if (selectedNodeIds.value.includes(node.id)) {
table.toggleRowSelection(node, true)
}
})
} finally {
nextTick(() => {
syncingSelection.value = false
})
}
})
}
async function loadNodeBoard() {
loading.value = true
errorMessage.value = ''
try {
const [nodesResponse, groupsResponse, routesResponse] = await Promise.all([
fetchNodes(),
getServerGroups(),
fetchNodeRoutes(),
])
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 {
loading.value = false
}
}
function handleReset() {
keyword.value = ''
typeFilter.value = 'all'
groupFilter.value = 'all'
statusFilter.value = 'all'
relationFilter.value = 'all'
gfwFilter.value = 'all'
currentPage.value = 1
}
function openNodeGroupManagement() {
void router.push('/node-groups')
}
function handleSelectionChange(selection: AdminNodeItem[]) {
if (syncingSelection.value) {
return
}
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,
auto_online: payload.auto_online,
}
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 handleBatchDelete() {
if (!hasSelectedNodes.value) {
ElMessage.warning('请先勾选需要批量删除的节点')
return
}
const deleteCount = selectedNodes.value.length
try {
await ElMessageBox.confirm(
`确认批量删除 ${deleteCount} 个节点吗?此操作不可恢复。`,
'批量删除节点',
{
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
},
)
batchDeleting.value = true
await batchDeleteNodes([...selectedNodeIds.value])
clearSelection()
ElMessage.success(`已批量删除 ${deleteCount} 个节点`)
await loadNodeBoard()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '批量删除失败')
} finally {
batchDeleting.value = false
}
}
async function handleCheckGfw(ids: number[], label: string) {
if (ids.length === 0) {
ElMessage.warning('请先选择需要检测的节点')
return
}
try {
const response = await checkNodeGfw(ids)
const started = response.data?.started?.length ?? 0
const skipped = response.data?.skipped?.length ?? 0
if (started > 0) {
ElMessage.success(`${label}已发起墙状态检测,${started} 个父节点等待上报`)
} else if (skipped > 0) {
ElMessage.info('所选节点均为子节点,墙状态随父节点显示')
} else {
ElMessage.info('没有可检测的节点')
}
await loadNodeBoard()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '墙状态检测发起失败')
}
}
async function handleBatchCheckGfw() {
if (!hasSelectedNodes.value) {
ElMessage.warning('请先勾选需要检测的节点')
return
}
batchGfwChecking.value = true
try {
await handleCheckGfw([...selectedNodeIds.value], '批量')
} finally {
batchGfwChecking.value = false
}
}
async function handleNodeCheckGfw(node: AdminNodeItem) {
if (node.parent_id) {
ElMessage.info('子节点不单独检测,墙状态随父节点显示')
return
}
markPending(workingIds, node.id, true)
try {
await handleCheckGfw([node.id], '')
} finally {
markPending(workingIds, node.id, false)
}
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
return
}
node.show = nextValue
markPending(switchingIds, node.id, true)
try {
await updateNode({
id: node.id,
show: nextValue ? 1 : 0,
})
ElMessage.success(nextValue ? '节点已显示' : '节点已隐藏')
} catch (error) {
node.show = previous
ElMessage.error(error instanceof Error ? error.message : '显隐状态更新失败')
} finally {
markPending(switchingIds, node.id, false)
}
}
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) {
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
}
if (action === 'check-gfw') {
await handleNodeCheckGfw(node)
return
}
markPending(workingIds, node.id, true)
try {
if (action === 'copy') {
await copyNode(node.id)
ElMessage.success('节点已复制')
await loadNodeBoard()
return
}
await ElMessageBox.confirm(
`删除节点 “${node.name}” 后无法恢复,确认继续吗?`,
'删除节点',
{ type: 'warning' },
)
await deleteNode(node.id)
ElMessage.success('节点已删除')
await loadNodeBoard()
} catch (error) {
if (action === 'delete' && (error === 'cancel' || error === 'close')) {
return
}
ElMessage.error(error instanceof Error ? error.message : '节点操作失败')
} finally {
markPending(workingIds, node.id, false)
}
}
onMounted(() => {
void loadNodeBoard()
})
watch(
() => route.query.group,
() => {
applyRouteGroupFilter()
},
)
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter, gfwFilter], () => {
currentPage.value = 1
})
watch(pageSize, () => {
currentPage.value = 1
})
watch(
() => filteredNodes.value.length,
() => {
setCurrentPageInRange()
},
)
watch(
() => paginatedNodes.value.map((item) => item.id).join(','),
() => {
syncTableSelection()
},
{ flush: 'post' },
)
</script>
<template>
<div class="nodes-page">
<section class="nodes-hero">
<div class="nodes-copy">
<p class="nodes-kicker">Nodes</p>
<h1>节点管理</h1>
<span>
现在可以在同一页完成节点筛选在线 / 离线排查分页浏览单行置顶批量修改与批量删除以及新增编辑显隐和删除等运营动作
</span>
</div>
<div class="hero-stats">
<article v-for="item in summaryCards" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="nodes-board">
<header class="board-toolbar">
<div class="toolbar-fields">
<ElButton type="primary" @click="openCreateEditor">
<ElIcon><Plus /></ElIcon>
添加节点
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索节点..."
class="toolbar-input"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElSelect v-model="typeFilter" class="toolbar-select" placeholder="类型">
<ElOption label="全部类型" value="all" />
<ElOption
v-for="option in typeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect v-model="groupFilter" class="toolbar-select" placeholder="权限组">
<ElOption label="全部权限组" value="all" />
<ElOption
v-for="group in groups"
:key="group.id"
:label="group.name"
:value="String(group.id)"
/>
</ElSelect>
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="状态">
<ElOption label="全部节点" value="all" />
<ElOption label="在线节点" value="online" />
<ElOption label="离线节点" value="offline" />
</ElSelect>
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
<ElOption label="全部节点" value="all" />
<ElOption label="父节点" value="parent" />
<ElOption label="子节点" value="child" />
</ElSelect>
<ElSelect v-model="gfwFilter" class="toolbar-select" placeholder="墙状态">
<ElOption label="全部墙状态" value="all" />
<ElOption label="正常" value="normal" />
<ElOption label="疑似被墙" value="blocked" />
<ElOption label="部分异常" value="partial" />
<ElOption label="检测失败" value="failed" />
<ElOption label="检测中" value="checking" />
<ElOption label="未检测" value="unchecked" />
<ElOption label="随父节点" value="inherited" />
</ElSelect>
</div>
<div class="toolbar-actions">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElButton :disabled="!hasSelectedNodes || batchDeleting" @click="openBatchEditor">批量修改</ElButton>
<ElButton
:disabled="!hasSelectedNodes || batchGfwChecking"
:loading="batchGfwChecking"
@click="handleBatchCheckGfw"
>
<ElIcon><Connection /></ElIcon>
检测墙状态
</ElButton>
<ElButton
type="danger"
plain
:disabled="!hasSelectedNodes"
:loading="batchDeleting"
@click="handleBatchDelete"
>
<ElIcon><Delete /></ElIcon>
批量删除
</ElButton>
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton @click="openSortEditor">编辑排序</ElButton>
</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"
show-icon
:closable="false"
class="board-alert"
:title="errorMessage"
>
<template #default>
<ElButton text @click="loadNodeBoard">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
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
round
effect="plain"
:type="row.parent_id ? 'warning' : 'success'"
class="id-tag"
>
{{ getNodeIdLabel(row) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="显隐" width="96">
<template #default="{ row }">
<div
class="switch-shell"
:style="{ '--node-switch-color': row.parent_id ? '#7c5cff' : '#22c55e' }"
>
<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">
<div class="node-cell__main">
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
<strong>{{ row.name }}</strong>
</div>
<div class="node-cell__sub">
<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
effect="plain"
:type="getNodeGfwMeta(row).tagType"
class="gfw-tag"
:class="`gfw-tag--${getNodeGfwMeta(row).tone}`"
>
{{ getNodeGfwMeta(row).label }}
</ElTag>
</ElTooltip>
<span>{{ getNodeTypeLabel(row.type) }}</span>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="地址" min-width="260">
<template #default="{ row }">
<div class="stack-cell">
<strong>{{ getNodeAddress(row).primary }}</strong>
<span>{{ getNodeAddress(row).secondary }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="在线人数" width="116">
<template #default="{ row }">
<div class="online-cell">
<span class="online-cell__primary">
<ElIcon><User /></ElIcon>
{{ row.online }}
</span>
<span>连接 {{ row.online_conn }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="倍率" width="96">
<template #default="{ row }">
<ElTag round effect="plain" class="rate-tag">
{{ formatNodeRate(row.rate) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="权限组" min-width="180">
<template #default="{ row }">
<div class="group-tags">
<ElTag
v-for="groupName in getNodeGroupNames(row)"
:key="`${row.id}-${groupName}`"
round
effect="plain"
>
{{ groupName }}
</ElTag>
<span v-if="getNodeGroupNames(row).length === 0" class="muted-copy">未分配</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="92" fixed="right">
<template #default="{ row }">
<ElDropdown
trigger="click"
@command="(command) => handleAction(command as NodeAction, row)"
>
<ElButton
text
class="action-trigger"
:loading="isWorking(row.id)"
>
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
<ElDropdownItem command="check-gfw" :disabled="Boolean(row.parent_id)">
检测墙状态
</ElDropdownItem>
<ElDropdownItem command="pin-top">置顶节点</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
<template #empty>
<div class="table-empty">
<ElEmpty
:description="hasActiveFilters ? '当前筛选条件下暂无节点。' : '暂无节点数据。'"
>
<ElButton v-if="hasActiveFilters" @click="handleReset">清空筛选</ElButton>
<ElButton v-else @click="loadNodeBoard">
<ElIcon><RefreshRight /></ElIcon>
重新加载
</ElButton>
</ElEmpty>
</div>
</template>
</ElTable>
<footer class="board-footer">
<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>
</div>
</footer>
</section>
<NodeEditorDialog
v-model:visible="editorVisible"
:mode="editorMode"
:node="activeNode"
:groups="groups"
:routes="routes"
:nodes="nodes"
@success="() => loadNodeBoard()"
/>
<NodeSortDialog
v-model:visible="sortDialogVisible"
:nodes="nodes"
@success="() => loadNodeBoard()"
/>
<NodeBatchEditDialog
v-model:visible="batchEditVisible"
:groups="groups"
:selected-count="selectedNodes.length"
:loading="batchSubmitting"
@submit="handleBatchSubmit"
/>
</div>
</template>
<style scoped>
.nodes-page {
display: grid;
gap: 24px;
}
.nodes-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.nodes-copy {
display: grid;
gap: 10px;
max-width: 680px;
}
.nodes-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.nodes-copy h1 {
font-size: clamp(36px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.nodes-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 320px;
}
.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;
}
.nodes-board {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.board-toolbar,
.toolbar-fields,
.toolbar-actions,
.board-footer,
.footer-hint,
.selection-summary {
display: flex;
align-items: center;
gap: 12px;
}
.board-toolbar,
.board-footer {
justify-content: space-between;
}
.toolbar-fields {
flex: 1;
flex-wrap: wrap;
}
.toolbar-input {
width: min(320px, 100%);
}
.toolbar-select {
width: 150px;
}
.board-alert {
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;
}
.nodes-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.switch-shell :deep(.el-switch) {
--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 {
display: grid;
gap: 6px;
}
.node-cell__main,
.node-cell__sub,
.online-cell__primary,
.footer-hint {
display: flex;
align-items: center;
gap: 8px;
}
.node-cell__sub {
flex-wrap: wrap;
}
.node-cell__main strong,
.stack-cell strong {
color: var(--xboard-text-strong);
}
.node-cell__sub span,
.stack-cell span,
.online-cell span,
.board-footer span,
.muted-copy {
color: var(--xboard-text-muted);
}
.node-dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
}
.node-dot.online {
background: #34c759;
}
.node-dot.pending {
background: #f5a623;
}
.node-dot.offline {
background: #ff5f57;
}
.node-dot.disabled {
background: #9ca3af;
}
.group-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rate-tag,
.id-tag,
.gfw-tag,
.auto-online-tag {
font-variant-numeric: tabular-nums;
}
.gfw-tag {
max-width: 150px;
}
.action-trigger {
font-size: 18px;
}
.table-empty {
padding: 24px 0;
}
.board-footer {
flex-wrap: wrap;
}
.footer-pagination {
margin-left: auto;
}
.footer-hint {
justify-content: flex-end;
color: var(--xboard-text-muted);
}
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,
.board-footer,
.selection-summary {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}
@media (max-width: 767px) {
.hero-stats {
grid-template-columns: 1fr;
}
.footer-hint {
justify-content: flex-start;
}
}
</style>