feat(admin-frontend): 新增系统与订阅管理后台页面

扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐
管理页、节点管理页及多个结构化占位页

补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、
失败作业详情与流量排行 limit 联动能力

同步后端 traffic rank limit 支持与知识库归档、设计约束、
验证配置及视觉验收产物
This commit is contained in:
yinjianm
2026-04-24 15:32:09 +08:00
parent 9ce345eb76
commit 16203b14f6
74 changed files with 6737 additions and 119 deletions
@@ -0,0 +1,628 @@
<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>