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
+151
View File
@@ -0,0 +1,151 @@
import type { AdminNodeItem } from '@/types/api'
export interface NodeStatusMeta {
label: string
dotClass: 'online' | 'pending' | 'offline' | 'disabled'
tagType: 'success' | 'warning' | 'danger' | 'info'
}
const NODE_TYPE_LABELS: Record<string, string> = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
vmess: 'VMess',
vless: 'VLESS',
hysteria: 'Hysteria 2',
tuic: 'TUIC',
anytls: 'AnyTLS',
socks: 'SOCKS',
http: 'HTTP',
naive: 'Naive',
mieru: 'Mieru',
}
function normalizeText(value: unknown): string {
return String(value ?? '').trim().toLowerCase()
}
export function getNodeTypeLabel(type: string): string {
const normalized = normalizeText(type)
return NODE_TYPE_LABELS[normalized] ?? String(type || '未知协议').toUpperCase()
}
export function getNodeStatusMeta(node: AdminNodeItem): NodeStatusMeta {
if (node.enabled === false) {
return {
label: '已停用',
dotClass: 'disabled',
tagType: 'info',
}
}
if (node.available_status === 2) {
return {
label: '在线',
dotClass: 'online',
tagType: 'success',
}
}
if (node.available_status === 1) {
return {
label: '待同步',
dotClass: 'pending',
tagType: 'warning',
}
}
return {
label: '离线',
dotClass: 'offline',
tagType: 'danger',
}
}
export function getNodeIdLabel(node: AdminNodeItem): string {
return node.parent_id ? `${node.id}${node.parent_id}` : String(node.id)
}
export function getNodeAddress(node: AdminNodeItem): { primary: string; secondary: string } {
const host = node.host || '--'
const publicPort = node.server_port ?? node.port ?? '--'
const innerPort = node.port ?? node.server_port ?? '--'
return {
primary: `${host}:${publicPort}`,
secondary: `内部端口 ${innerPort}`,
}
}
export function formatNodeRate(rate?: number | null): string {
const normalized = Number.isFinite(Number(rate)) ? Number(rate) : 1
return `${normalized.toFixed(2)} x`
}
export function getNodeGroupNames(node: AdminNodeItem): string[] {
return (node.groups ?? [])
.map((group) => group.name)
.filter(Boolean)
}
export function buildNodeTypeOptions(nodes: AdminNodeItem[]): Array<{ label: string; value: string }> {
const uniqueTypes = [...new Set(nodes.map((node) => normalizeText(node.type)).filter(Boolean))]
return uniqueTypes.map((value) => ({
value,
label: getNodeTypeLabel(value),
}))
}
function buildNodeSearchText(node: AdminNodeItem): string {
return [
node.id,
node.parent_id,
node.name,
node.host,
node.port,
node.server_port,
getNodeTypeLabel(node.type),
...getNodeGroupNames(node),
]
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function filterNodes(
nodes: AdminNodeItem[],
keyword: string,
typeFilter: string,
groupFilter: string,
): AdminNodeItem[] {
const normalizedKeyword = normalizeText(keyword)
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
return nodes.filter((node) => {
if (normalizedKeyword && !buildNodeSearchText(node).includes(normalizedKeyword)) {
return false
}
if (normalizedType !== '' && normalizedType !== 'all' && normalizeText(node.type) !== normalizedType) {
return false
}
if (normalizedGroup !== '' && normalizedGroup !== 'all') {
const belongsToGroup = (node.groups ?? []).some((group) => String(group.id) === normalizedGroup)
if (!belongsToGroup) {
return false
}
}
return true
})
}
export function countOnlineNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => getNodeStatusMeta(node).dotClass === 'online').length
}
export function countVisibleNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => Boolean(node.show)).length
}