feat(admin-frontend): 新增系统与订阅管理后台页面
扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐 管理页、节点管理页及多个结构化占位页 补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、 失败作业详情与流量排行 limit 联动能力 同步后端 traffic rank limit 支持与知识库归档、设计约束、 验证配置及视觉验收产物
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user