Files
Xboard/admin-frontend/src/views/nodes/NodesView.vue
T
yinjianm 4c66d1fbe0 feat(ui): 新增自动墙检倒计时提示
精简节点管理页批量操作与工作台说明文案,
并在表格底部新增基于 30 分钟调度节奏估算的
下次自动墙检倒计时提示,便于用户了解任务状态
2026-04-28 14:28:57 +08:00

1338 lines
36 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, onBeforeUnmount, 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,
countAutoGfwCheckNodes,
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 gfwSwitchingIds = 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 currentTimestamp = ref(Math.floor(Date.now() / 1000))
let autoCheckCountdownTimer: number | undefined
const GFW_AUTO_CHECK_INTERVAL_MINUTES = 30
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(countAutoGfwCheckNodes(nodes.value)) },
{ label: '已勾选', value: String(selectedNodes.value.length) },
])
const batchTargetLabel = computed(() => (hasSelectedNodes.value ? `当前已选 ${selectedNodes.value.length} 个节点` : ''))
const autoGfwParentNodes = computed(() => nodes.value.filter((node) => !node.parent_id && node.gfw_check_enabled !== false))
const hasRunningAutoGfwTask = computed(() => autoGfwParentNodes.value.some((node) => {
const status = String(node.gfw_check?.status ?? '').toLowerCase()
return status === 'pending' || status === 'checking'
}))
const nextAutoGfwCheckHint = computed(() => {
if (autoGfwParentNodes.value.length === 0) {
return '未开启父节点自动墙检'
}
if (hasRunningAutoGfwTask.value) {
return '本轮自动墙检进行中'
}
const nextTimestamp = getNextAutoGfwCheckTimestamp(currentTimestamp.value)
return `下次自动墙检:${formatCountdown(nextTimestamp - currentTimestamp.value)}${formatClockTime(nextTimestamp)}`
})
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 isGfwSwitching(id: number): boolean {
return gfwSwitchingIds.value.includes(id)
}
function isWorking(id: number): boolean {
return workingIds.value.includes(id)
}
function getNextAutoGfwCheckTimestamp(timestamp: number): number {
const nextRun = new Date(timestamp * 1000)
const minutes = nextRun.getMinutes()
nextRun.setSeconds(0, 0)
if (minutes < GFW_AUTO_CHECK_INTERVAL_MINUTES) {
nextRun.setMinutes(GFW_AUTO_CHECK_INTERVAL_MINUTES)
} else {
nextRun.setHours(nextRun.getHours() + 1)
nextRun.setMinutes(0)
}
return Math.floor(nextRun.getTime() / 1000)
}
function formatCountdown(seconds: number): string {
const minutes = Math.max(1, Math.ceil(seconds / 60))
return `${minutes} 分钟后`
}
function formatClockTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}
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,
gfw_check_enabled: payload.gfw_check_enabled,
}
try {
await ElMessageBox.confirm(
`确认批量修改 ${selectedNodeIds.value.length} 个节点吗?本次只会更新已启用的字段。`,
'批量修改节点',
{ type: 'warning' },
)
batchSubmitting.value = true
await batchUpdateNodes(updatePayload)
let started = 0
let skipped = 0
if (payload.gfw_check_enabled === true) {
const response = await checkNodeGfw(updatePayload.ids)
started = response.data?.started?.length ?? 0
skipped = response.data?.skipped?.length ?? 0
}
batchEditVisible.value = false
clearSelection()
if (started > 0) {
ElMessage.success(`已批量更新 ${updatePayload.ids.length} 个节点,并发起 ${started} 个父节点墙检测`)
} else if (payload.gfw_check_enabled === true && skipped > 0) {
ElMessage.info('已批量开启墙检测托管,所选父节点已有任务或所选节点为子节点')
} else {
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) {
const reason = response.data?.skipped?.[0]?.reason
ElMessage.info(reason || '所选节点暂未发起新的墙状态检测')
} 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 handleToggleGfwCheck(node: AdminNodeItem, nextValue: boolean) {
const previous = node.gfw_check_enabled !== false
if (previous === nextValue) {
return
}
node.gfw_check_enabled = nextValue
markPending(gfwSwitchingIds, node.id, true)
try {
await updateNode({
id: node.id,
gfw_check_enabled: nextValue,
})
if (nextValue && !node.parent_id) {
const response = await checkNodeGfw([node.id])
const started = response.data?.started?.length ?? 0
if (started > 0) {
ElMessage.success('已开启墙检测托管,并发起墙状态检测')
} else {
const reason = response.data?.skipped?.[0]?.reason
ElMessage.info(reason || '已开启墙检测托管,已有检测任务等待节点领取或上报')
}
} else {
ElMessage.success(nextValue ? '已开启墙检测托管' : '已关闭墙检测托管')
}
await loadNodeBoard()
} catch (error) {
node.gfw_check_enabled = previous
ElMessage.error(error instanceof Error ? error.message : '墙检测托管状态更新失败')
} finally {
markPending(gfwSwitchingIds, 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(() => {
currentTimestamp.value = Math.floor(Date.now() / 1000)
autoCheckCountdownTimer = window.setInterval(() => {
currentTimestamp.value = Math.floor(Date.now() / 1000)
}, 30 * 1000)
void loadNodeBoard()
})
onBeforeUnmount(() => {
if (autoCheckCountdownTimer !== undefined) {
window.clearInterval(autoCheckCountdownTimer)
}
})
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 v-if="batchTargetLabel" 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
:loading="loading"
@click="loadNodeBoard"
>
<ElIcon><RefreshRight /></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) || (Boolean(row.gfw_auto_hidden) && row.gfw_check_enabled !== false)"
@change="(value) => handleToggleShow(row, Boolean(value))"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="墙检测" width="118">
<template #default="{ row }">
<ElTooltip
:content="row.parent_id ? '子节点不单独检测;此开关只控制是否随父节点自动隐藏或恢复。' : '关闭后不参与自动墙检测和墙状态自动显隐。'"
placement="top"
>
<div class="switch-shell switch-shell--gfw">
<ElSwitch
:model-value="row.gfw_check_enabled !== false"
:loading="isGfwSwitching(row.id)"
@change="(value) => handleToggleGfwCheck(row, Boolean(value))"
/>
</div>
</ElTooltip>
</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>
<ElTag
v-if="row.gfw_check_enabled !== false"
round
effect="plain"
type="primary"
class="auto-online-tag"
>
墙检测
</ElTag>
<ElTag
v-if="row.gfw_auto_hidden"
round
effect="plain"
type="danger"
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"
/>
<ElTooltip
content="按 Laravel Scheduler 每 30 分钟调度估算;scheduler 未运行时不会自动创建检测任务。"
placement="top"
>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>{{ nextAutoGfwCheckHint }}</span>
</div>
</ElTooltip>
</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;
}
.switch-shell--gfw :deep(.el-switch) {
--el-switch-on-color: #34c759;
}
.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>