16203b14f6
扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐 管理页、节点管理页及多个结构化占位页 补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、 失败作业详情与流量排行 limit 联动能力 同步后端 traffic rank limit 支持与知识库归档、设计约束、 验证配置及视觉验收产物
629 lines
15 KiB
Vue
629 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import {
|
||
Connection,
|
||
MoreFilled,
|
||
Plus,
|
||
RefreshRight,
|
||
Search,
|
||
User,
|
||
} from '@element-plus/icons-vue'
|
||
import {
|
||
copyNode,
|
||
deleteNode,
|
||
fetchNodes,
|
||
getServerGroups,
|
||
updateNode,
|
||
} from '@/api/admin'
|
||
import type { AdminNodeItem, AdminServerGroupItem } from '@/types/api'
|
||
import {
|
||
buildNodeTypeOptions,
|
||
countOnlineNodes,
|
||
countVisibleNodes,
|
||
filterNodes,
|
||
formatNodeRate,
|
||
getNodeAddress,
|
||
getNodeGroupNames,
|
||
getNodeIdLabel,
|
||
getNodeStatusMeta,
|
||
getNodeTypeLabel,
|
||
} from '@/utils/nodes'
|
||
|
||
type NodeAction = 'edit' | 'copy' | 'delete'
|
||
|
||
const loading = ref(false)
|
||
const errorMessage = ref('')
|
||
const nodes = ref<AdminNodeItem[]>([])
|
||
const groups = ref<AdminServerGroupItem[]>([])
|
||
const keyword = ref('')
|
||
const typeFilter = ref('all')
|
||
const groupFilter = ref('all')
|
||
const switchingIds = ref<number[]>([])
|
||
const workingIds = ref<number[]>([])
|
||
|
||
const filteredNodes = computed(() => filterNodes(
|
||
nodes.value,
|
||
keyword.value,
|
||
typeFilter.value,
|
||
groupFilter.value,
|
||
))
|
||
|
||
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
|
||
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.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(filteredNodes.value.length) },
|
||
])
|
||
|
||
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 isWorking(id: number): boolean {
|
||
return workingIds.value.includes(id)
|
||
}
|
||
|
||
function notifyPending(scope: string) {
|
||
ElMessage.info(`${scope} 会在下一阶段接入,本轮已先打通节点列表主链路。`)
|
||
}
|
||
|
||
async function loadNodeBoard() {
|
||
loading.value = true
|
||
errorMessage.value = ''
|
||
|
||
try {
|
||
const [nodesResponse, groupsResponse] = await Promise.all([
|
||
fetchNodes(),
|
||
getServerGroups(),
|
||
])
|
||
|
||
nodes.value = nodesResponse.data ?? []
|
||
groups.value = groupsResponse.data ?? []
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleReset() {
|
||
keyword.value = ''
|
||
typeFilter.value = 'all'
|
||
groupFilter.value = 'all'
|
||
}
|
||
|
||
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 handleAction(action: NodeAction, node: AdminNodeItem) {
|
||
if (action === 'edit') {
|
||
notifyPending(`编辑节点 #${node.id}`)
|
||
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()
|
||
})
|
||
</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="notifyPending('添加节点')">
|
||
<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>
|
||
</div>
|
||
|
||
<div class="toolbar-actions">
|
||
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
|
||
<ElIcon><RefreshRight /></ElIcon>
|
||
重置筛选
|
||
</ElButton>
|
||
<ElButton @click="notifyPending('编辑排序')">编辑排序</ElButton>
|
||
</div>
|
||
</header>
|
||
|
||
<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
|
||
:data="filteredNodes"
|
||
v-loading="loading"
|
||
row-key="id"
|
||
class="nodes-table"
|
||
>
|
||
<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)"
|
||
@change="(value) => handleToggleShow(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>
|
||
<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="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>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
|
||
<div class="footer-hint">
|
||
<ElIcon><Connection /></ElIcon>
|
||
<span>完整的节点创建、编辑与排序流程将在下一阶段补齐。</span>
|
||
</div>
|
||
</footer>
|
||
</section>
|
||
</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 {
|
||
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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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__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 {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.action-trigger {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.table-empty {
|
||
padding: 24px 0;
|
||
}
|
||
|
||
.board-footer {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.footer-hint {
|
||
justify-content: flex-end;
|
||
color: var(--xboard-text-muted);
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.nodes-hero,
|
||
.board-toolbar,
|
||
.board-footer {
|
||
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;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.hero-stats {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.footer-hint {
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
</style>
|