73b1696b0a
为节点新增 auto_online 字段与后台同步任务, 仅对开启托管的节点按在线状态自动同步前台显示。 管理端补齐单节点与批量开关、列表标识与统计, 并在自动上线启用时禁用手动显隐切换。 后端新增定时命令、保存校验、批量更新支持、 数据库迁移与单元测试,保证托管逻辑可落地。
1175 lines
30 KiB
Vue
1175 lines
30 KiB
Vue
<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>
|