feat(admin-frontend): 完成节点与礼品卡管理工作台

补齐节点管理真实新增、编辑与排序流程,接入权限组与路由组
维护页,并支持 11 种协议的动态配置表单

开放礼品卡管理入口,交付模板、兑换码、使用记录与统计四页签
工作台,接入 gift-card 相关后台接口

将知识库、权限组与路由管理从占位页升级为真实页面,并修复侧边栏
低高度裁切问题

修复仪表盘 24h 流量排行涨跌始终为 0 的问题,改为对比昨天整日统
计并补充单元测试
This commit is contained in:
yinjianm
2026-04-24 21:58:16 +08:00
parent f7cef30b9c
commit e393b11b61
80 changed files with 8811 additions and 278 deletions
+189
View File
@@ -0,0 +1,189 @@
.node-editor-dialog {
.node-editor-shell {
display: grid;
gap: 20px;
max-height: min(78vh, 980px);
overflow: auto;
padding-right: 6px;
}
.node-editor-hero,
.hero-copy__title,
.switch-row,
.switch-panel,
.node-editor-footer,
.footer-actions,
.protocol-option,
.rate-item__footer {
display: flex;
align-items: center;
gap: 12px;
}
.node-editor-hero,
.node-editor-footer,
.rate-item__footer {
justify-content: space-between;
}
.hero-copy,
.node-editor-form,
.form-section,
.section-head,
.form-placeholder,
.rate-list {
display: grid;
gap: 16px;
}
.hero-copy {
gap: 8px;
}
.hero-copy h2 {
margin: 0;
font-size: clamp(28px, 4vw, 34px);
line-height: 1.08;
letter-spacing: -0.24px;
color: var(--xboard-text-strong);
}
.hero-copy p,
.section-head p,
.switch-row span,
.placeholder-copy,
.footer-hint,
.rate-item__footer span {
color: var(--xboard-text-muted);
line-height: 1.55;
}
.hero-protocol {
display: grid;
gap: 8px;
min-width: min(260px, 100%);
}
.hero-protocol__label {
color: var(--xboard-text-secondary);
font-size: 13px;
}
.protocol-badge {
background: #111111;
border-color: #111111;
}
.protocol-option__dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
}
.form-section {
padding: 20px;
border-radius: 22px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.section-head h3 {
margin: 0;
color: var(--xboard-text-strong);
font-size: 18px;
}
.form-grid,
.rate-item__grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid--full {
grid-column: 1 / -1;
}
.full-width,
.full-width .el-input__wrapper {
width: 100%;
}
.switch-row,
.switch-card {
justify-content: space-between;
}
.switch-row strong,
.switch-card strong {
display: block;
color: var(--xboard-text-strong);
margin-bottom: 4px;
}
.switch-panel {
align-items: stretch;
flex-wrap: wrap;
}
.switch-card {
flex: 1 1 280px;
padding: 14px 16px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.rate-item {
display: grid;
gap: 12px;
padding: 16px;
border-radius: 18px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.form-placeholder {
padding: 24px;
border-radius: 22px;
background: #fbfbfd;
}
.node-editor-footer {
width: 100%;
}
@media (max-width: 960px) {
.node-editor-hero,
.node-editor-footer {
flex-direction: column;
align-items: stretch;
}
.hero-copy__title {
align-items: flex-start;
flex-wrap: wrap;
}
.hero-protocol {
min-width: 0;
}
}
@media (max-width: 767px) {
.form-grid,
.rate-item__grid {
grid-template-columns: 1fr;
}
.switch-row,
.switch-card,
.node-editor-footer,
.footer-actions,
.rate-item__footer {
flex-direction: column;
align-items: stretch;
}
}
}
@@ -0,0 +1,415 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { saveNode } from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem, AdminNodeType, AdminServerGroupItem } from '@/types/api'
import NodeEditorProtocolSection from './NodeEditorProtocolSection.vue'
import {
createEmptyNodeForm,
createNodeRateRange,
getNodeProtocolLabel,
getNodeProtocolOptions,
toNodeFormModel,
toNodeSavePayload,
type NodeFormModel,
validateNodeForm,
} from '@/utils/nodeEditor'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
node?: AdminNodeItem | null
groups: AdminServerGroupItem[]
routes: AdminNodeRouteItem[]
nodes: AdminNodeItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeFormModel>(createEmptyNodeForm())
const protocolOptions = computed(() => getNodeProtocolOptions())
const dialogTitle = computed(() => props.mode === 'create' ? '新建节点' : '编辑节点')
const dialogDescription = computed(() => props.mode === 'create'
? '管理所有节点,包括添加、删除、编辑等操作。'
: '调整节点基础配置、协议细节与排序前置参数。')
const currentProtocolLabel = computed(() => getNodeProtocolLabel(form.type))
const parentNodeOptions = computed(() => props.nodes.filter((item) => item.id !== props.node?.id))
const rules = computed<FormRules<NodeFormModel>>(() => ({
type: [{ required: true, message: '请选择协议类型', trigger: 'change' }],
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入节点地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入连接端口', trigger: 'blur' }],
serverPort: [{ required: true, message: '请输入服务端口', trigger: 'blur' }],
rate: [
{
validator: (_rule, value, callback) => {
if (!Number.isFinite(Number(value)) || Number(value) <= 0) {
callback(new Error('请输入大于 0 的倍率'))
return
}
callback()
},
trigger: 'blur',
},
],
}))
function closeDialog() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toNodeFormModel(props.node))
}
function applyProtocolDefaults(type: AdminNodeType | '') {
if (!type) {
form.network = ''
form.tlsMode = 0
return
}
if (['vmess', 'vless', 'trojan'].includes(type) && !form.network) {
form.network = 'tcp'
}
if (!['vmess', 'vless', 'trojan'].includes(type)) {
form.network = ''
}
if (!['vmess', 'vless', 'trojan', 'hysteria', 'tuic', 'anytls', 'socks', 'naive', 'http'].includes(type)) {
form.tlsMode = 0
}
if (type === 'trojan' && form.tlsMode === 0) {
form.tlsMode = 1
}
}
function addRateRange() {
form.rateTimeRanges.push(createNodeRateRange())
}
function removeRateRange(index: number) {
if (form.rateTimeRanges.length === 1) {
form.rateTimeRanges.splice(0, 1, createNodeRateRange())
return
}
form.rateTimeRanges.splice(index, 1)
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
const validationMessage = validateNodeForm(form)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
submitting.value = true
try {
await saveNode(toNodeSavePayload(form))
const message = props.mode === 'create' ? '节点已创建' : '节点已更新'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.node, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
applyProtocolDefaults(form.type)
nextTick(() => {
formRef.value?.clearValidate()
})
},
{ immediate: true },
)
watch(
() => form.type,
(value) => {
applyProtocolDefaults(value)
},
)
watch(
() => form.tlsMode,
(value) => {
if (value !== 2) {
form.realityServerName = ''
form.realityServerPort = ''
form.realityPublicKey = ''
form.realityPrivateKey = ''
form.realityShortId = ''
}
if (value === 0) {
form.tlsServerName = ''
form.tlsAllowInsecure = false
form.echEnabled = false
form.echConfig = ''
form.echQueryServerName = ''
form.echKey = ''
form.utlsEnabled = false
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(960px, calc(100vw - 24px))"
top="4vh"
destroy-on-close
class="node-editor-dialog"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="node-editor-shell">
<header class="node-editor-hero">
<div class="hero-copy">
<div class="hero-copy__title">
<h2>{{ dialogTitle }}</h2>
<ElTag v-if="form.type" round effect="dark" class="protocol-badge">
{{ currentProtocolLabel }}
</ElTag>
</div>
<p>{{ dialogDescription }}</p>
</div>
<div class="hero-protocol">
<span class="hero-protocol__label">选择协议类型</span>
<ElSelect v-model="form.type" placeholder="选择协议类型">
<ElOption
v-for="option in protocolOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="protocol-option">
<span class="protocol-option__dot" :style="{ background: option.dotColor }" />
<span>{{ option.label }}</span>
</div>
</ElOption>
</ElSelect>
</div>
</header>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="node-editor-form"
>
<section class="form-section">
<div class="section-head">
<div>
<h3>基础信息</h3>
<p>先完成节点标识地址权限组与展示状态等通用配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="节点名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入节点名称" />
</ElFormItem>
<ElFormItem label="基础倍率" prop="rate">
<ElInputNumber
v-model="form.rate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="启用动态倍率" class="form-grid--full">
<div class="switch-row">
<div>
<strong>根据时间段设置不同的倍率乘数</strong>
<span>关闭后仅使用基础倍率开启后可配置多个倍率区间</span>
</div>
<ElSwitch v-model="form.rateTimeEnable" />
</div>
</ElFormItem>
<ElFormItem label="自定义节点 ID(选填)">
<ElInput v-model="form.code" placeholder="请输入自定义节点 ID" />
</ElFormItem>
<ElFormItem label="节点标签">
<ElSelect
v-model="form.tags"
multiple
filterable
allow-create
default-first-option
collapse-tags
collapse-tags-tooltip
placeholder="输入后回车添加标签"
/>
</ElFormItem>
<ElFormItem label="权限组">
<ElSelect v-model="form.groupIds" multiple collapse-tags collapse-tags-tooltip placeholder="请选择权限组">
<ElOption
v-for="group in props.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="父级节点">
<ElSelect v-model="form.parentId" clearable placeholder="无">
<ElOption
v-for="node in parentNodeOptions"
:key="node.id"
:label="`${node.name} (#${node.id})`"
:value="node.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="节点地址" prop="host" class="form-grid--full">
<ElInput v-model="form.host" placeholder="请输入节点域名或者 IP" />
</ElFormItem>
<ElFormItem label="连接端口" prop="port">
<ElInput v-model="form.port" placeholder="用户连接端口" />
</ElFormItem>
<ElFormItem label="服务端口" prop="serverPort">
<ElInput v-model="form.serverPort" placeholder="请输入服务端口" />
</ElFormItem>
<ElFormItem label="路由组" class="form-grid--full">
<ElSelect v-model="form.routeIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择路由组">
<ElOption
v-for="route in props.routes"
:key="route.id"
:label="route.remarks"
:value="route.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="节点状态" class="form-grid--full">
<div class="switch-panel">
<label class="switch-card">
<div>
<strong>前台显示</strong>
<span>开启后节点会出现在可展示列表中</span>
</div>
<ElSwitch v-model="form.show" />
</label>
<label class="switch-card">
<div>
<strong>启用节点</strong>
<span>关闭后节点仍保留配置但视为停用状态</span>
</div>
<ElSwitch v-model="form.enabled" />
</label>
</div>
</ElFormItem>
</div>
</section>
<section v-if="form.rateTimeEnable" class="form-section">
<div class="section-head">
<div>
<h3>动态倍率</h3>
<p>按时间段定义倍率规则保存时会序列化为 `rate_time_ranges`</p>
</div>
<ElButton @click="addRateRange">
<ElIcon><Plus /></ElIcon>
添加时间段
</ElButton>
</div>
<div class="rate-list">
<article
v-for="(item, index) in form.rateTimeRanges"
:key="item.key"
class="rate-item"
>
<div class="rate-item__grid">
<ElFormItem label="开始时间">
<ElTimePicker v-model="item.start" value-format="HH:mm" format="HH:mm" placeholder="09:00" />
</ElFormItem>
<ElFormItem label="结束时间">
<ElTimePicker v-model="item.end" value-format="HH:mm" format="HH:mm" placeholder="18:00" />
</ElFormItem>
<ElFormItem label="倍率">
<ElInputNumber
v-model="item.rate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</ElFormItem>
</div>
<div class="rate-item__footer">
<span>规则 {{ index + 1 }}</span>
<ElButton text type="danger" @click="removeRateRange(index)">删除</ElButton>
</div>
</article>
</div>
</section>
<section v-if="!form.type" class="form-placeholder">
<ElEmpty description="请选择协议类型后继续配置协议参数。">
<p class="placeholder-copy">不同协议会自动切换不同的安全层传输层与专属配置项</p>
</ElEmpty>
</section>
<NodeEditorProtocolSection v-else :form="form" />
</ElForm>
</div>
<template #footer>
<div class="node-editor-footer">
<span class="footer-hint">当前协议{{ form.type ? currentProtocolLabel : '未选择' }}</span>
<div class="footer-actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style lang="scss" src="./NodeEditorDialog.scss"></style>
@@ -0,0 +1,494 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
getNodeProtocolHint,
NODE_CONGESTION_CONTROL_OPTIONS,
NODE_MUX_PROTOCOL_OPTIONS,
NODE_SHADOWSOCKS_CIPHER_OPTIONS,
NODE_SHADOWSOCKS_OBFS_OPTIONS,
NODE_TCP_HEADER_OPTIONS,
NODE_TLS_FINGERPRINT_OPTIONS,
NODE_UDP_RELAY_MODE_OPTIONS,
NODE_VLESS_FLOW_OPTIONS,
shouldShowRealitySettings,
shouldShowTlsSettings,
supportsNodeMultiplex,
supportsNodeSecurity,
supportsNodeTransport,
getNodeTlsOptions,
getNodeTransportOptions,
type NodeFormModel,
} from '@/utils/nodeEditor'
const props = defineProps<{
form: NodeFormModel
}>()
const transportOptions = computed(() => getNodeTransportOptions(props.form.type))
const tlsOptions = computed(() => getNodeTlsOptions(props.form.type))
const showSecuritySection = computed(() => supportsNodeSecurity(props.form.type))
const showTransportSection = computed(() => supportsNodeTransport(props.form.type))
const showMultiplexSection = computed(() => supportsNodeMultiplex(props.form.type))
const showTlsSection = computed(() => shouldShowTlsSettings(props.form.type, props.form.tlsMode))
const showRealitySection = computed(() => shouldShowRealitySettings(props.form.type, props.form.tlsMode))
const currentProtocolHint = computed(() => getNodeProtocolHint(props.form.type))
</script>
<template>
<section v-if="showSecuritySection" class="form-section">
<div class="section-head">
<div>
<h3>安全层</h3>
<p>根据协议切换 TLS / Reality / ECH / uTLS 等安全配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem
v-if="['vmess', 'vless', 'trojan', 'socks', 'naive', 'http'].includes(props.form.type)"
label="安全性"
>
<ElSelect v-model="props.form.tlsMode" placeholder="请选择安全性">
<ElOption
v-for="option in tlsOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="['hysteria', 'tuic', 'anytls'].includes(props.form.type)"
label="服务器名称(SNI"
>
<ElInput v-model="props.form.tlsServerName" placeholder="example.com" />
</ElFormItem>
<ElFormItem v-if="showTlsSection" label="服务器名称(SNI">
<ElInput v-model="props.form.tlsServerName" placeholder="example.com" />
</ElFormItem>
<ElFormItem v-if="showTlsSection || ['hysteria', 'tuic', 'anytls'].includes(props.form.type)" label="允许不安全连接">
<ElSwitch v-model="props.form.tlsAllowInsecure" />
</ElFormItem>
<ElFormItem v-if="showTlsSection || ['hysteria', 'tuic', 'anytls'].includes(props.form.type)" label="启用 ECH" class="form-grid--full">
<div class="switch-row">
<div>
<strong>Encrypted Client Hello</strong>
<span>用于支持 ECH TLS 场景关闭时不会写入 ECH 配置</span>
</div>
<ElSwitch v-model="props.form.echEnabled" />
</div>
</ElFormItem>
<template v-if="props.form.echEnabled">
<ElFormItem label="ECH Config" class="form-grid--full">
<ElInput
v-model="props.form.echConfig"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="粘贴 ECH Config"
/>
</ElFormItem>
<ElFormItem label="ECH 查询域名">
<ElInput v-model="props.form.echQueryServerName" placeholder="ech.example.com" />
</ElFormItem>
<ElFormItem label="ECH Key(可选)">
<ElInput v-model="props.form.echKey" placeholder="仅服务端维护时填写" />
</ElFormItem>
</template>
<ElFormItem
v-if="showTlsSection || showRealitySection"
label="启用 uTLS"
class="form-grid--full"
>
<div class="switch-row">
<div>
<strong>uTLS 指纹伪装</strong>
<span>适用于需要模拟客户端指纹的连接场景</span>
</div>
<ElSwitch v-model="props.form.utlsEnabled" />
</div>
</ElFormItem>
<ElFormItem v-if="props.form.utlsEnabled" label="uTLS 指纹">
<ElSelect v-model="props.form.utlsFingerprint" placeholder="请选择指纹">
<ElOption
v-for="option in NODE_TLS_FINGERPRINT_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="showRealitySection">
<ElFormItem label="Reality 服务器名称">
<ElInput v-model="props.form.realityServerName" placeholder="www.cloudflare.com" />
</ElFormItem>
<ElFormItem label="Reality 服务器端口">
<ElInput v-model="props.form.realityServerPort" placeholder="443" />
</ElFormItem>
<ElFormItem label="Reality 公钥" class="form-grid--full">
<ElInput v-model="props.form.realityPublicKey" placeholder="请输入公钥" />
</ElFormItem>
<ElFormItem label="Reality 私钥" class="form-grid--full">
<ElInput v-model="props.form.realityPrivateKey" placeholder="仅服务端维护时填写" />
</ElFormItem>
<ElFormItem label="Reality Short ID">
<ElInput v-model="props.form.realityShortId" placeholder="请输入 Short ID" />
</ElFormItem>
</template>
</div>
</section>
<section v-if="showTransportSection" class="form-section">
<div class="section-head">
<div>
<h3>传输层</h3>
<p>按不同传输协议切换对应字段避免把所有参数堆到同一层</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="传输协议">
<ElSelect v-model="props.form.network" placeholder="请选择传输协议">
<ElOption
v-for="option in transportOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="props.form.network === 'tcp'" label="TCP 头部类型">
<ElSelect v-model="props.form.tcpHeaderType" placeholder="请选择头部类型">
<ElOption
v-for="option in NODE_TCP_HEADER_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="props.form.network === 'tcp' && props.form.tcpHeaderType === 'http'">
<ElFormItem label="请求路径" class="form-grid--full">
<ElInput
v-model="props.form.tcpRequestPath"
type="textarea"
:autosize="{ minRows: 3, maxRows: 4 }"
placeholder="每行一个 path,例如:&#10;/api&#10;/ws"
/>
</ElFormItem>
<ElFormItem label="Host 列表" class="form-grid--full">
<ElInput v-model="props.form.tcpRequestHost" placeholder="多个 Host 用逗号分隔" />
</ElFormItem>
</template>
<template v-if="props.form.network === 'ws'">
<ElFormItem label="路径">
<ElInput v-model="props.form.wsPath" placeholder="/ws" />
</ElFormItem>
<ElFormItem label="Host">
<ElInput v-model="props.form.wsHost" placeholder="ws.example.com" />
</ElFormItem>
</template>
<ElFormItem v-if="props.form.network === 'grpc'" label="Service Name">
<ElInput v-model="props.form.grpcServiceName" placeholder="grpc-service" />
</ElFormItem>
<template v-if="props.form.network === 'h2'">
<ElFormItem label="路径">
<ElInput v-model="props.form.h2Path" placeholder="/" />
</ElFormItem>
<ElFormItem label="Host 列表">
<ElInput v-model="props.form.h2Host" placeholder="多个 Host 用逗号分隔" />
</ElFormItem>
</template>
<template v-if="props.form.network === 'httpupgrade'">
<ElFormItem label="路径">
<ElInput v-model="props.form.httpupgradePath" placeholder="/upgrade" />
</ElFormItem>
<ElFormItem label="Host">
<ElInput v-model="props.form.httpupgradeHost" placeholder="upgrade.example.com" />
</ElFormItem>
</template>
<template v-if="props.form.network === 'xhttp'">
<ElFormItem label="路径">
<ElInput v-model="props.form.xhttpPath" placeholder="/connect" />
</ElFormItem>
<ElFormItem label="Host">
<ElInput v-model="props.form.xhttpHost" placeholder="xhttp.example.com" />
</ElFormItem>
<ElFormItem label="模式">
<ElInput v-model="props.form.xhttpMode" placeholder="auto" />
</ElFormItem>
<ElFormItem label="额外参数 JSON" class="form-grid--full">
<ElInput
v-model="props.form.xhttpExtra"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder='例如:{ "mode": "stream-one" }'
/>
</ElFormItem>
</template>
<template v-if="props.form.network === 'kcp'">
<ElFormItem label="Seed">
<ElInput v-model="props.form.kcpSeed" placeholder="请输入 mKCP seed" />
</ElFormItem>
<ElFormItem label="KCP 头部类型">
<ElSelect v-model="props.form.kcpHeaderType" placeholder="请选择头部类型">
<ElOption
v-for="option in NODE_TCP_HEADER_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
</template>
</div>
</section>
<section class="form-section">
<div class="section-head">
<div>
<h3>协议配置</h3>
<p>{{ currentProtocolHint }}</p>
</div>
</div>
<div class="form-grid">
<template v-if="props.form.type === 'shadowsocks'">
<ElFormItem label="加密方式">
<ElSelect v-model="props.form.shadowsocksCipher" placeholder="请选择加密方式">
<ElOption
v-for="option in NODE_SHADOWSOCKS_CIPHER_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="混淆方式">
<ElSelect v-model="props.form.shadowsocksObfs" placeholder="请选择混淆">
<ElOption
v-for="option in NODE_SHADOWSOCKS_OBFS_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="props.form.shadowsocksObfs" label="混淆 Host">
<ElInput v-model="props.form.shadowsocksObfsHost" placeholder="obfs host" />
</ElFormItem>
<ElFormItem v-if="props.form.shadowsocksObfs" label="混淆路径">
<ElInput v-model="props.form.shadowsocksObfsPath" placeholder="/path" />
</ElFormItem>
<ElFormItem label="Plugin">
<ElInput v-model="props.form.shadowsocksPlugin" placeholder="v2ray-plugin" />
</ElFormItem>
<ElFormItem label="Plugin 参数">
<ElInput v-model="props.form.shadowsocksPluginOpts" placeholder="server;tls;host=example.com" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'vless'">
<ElFormItem label="Flow">
<ElSelect v-model="props.form.vlessFlow" placeholder="请选择 Flow">
<ElOption
v-for="option in NODE_VLESS_FLOW_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="启用自定义加密" class="form-grid--full">
<div class="switch-row">
<div>
<strong>VLess Encryption</strong>
<span>适用于需要额外加解密配置的场景</span>
</div>
<ElSwitch v-model="props.form.vlessEncryptionEnabled" />
</div>
</ElFormItem>
<ElFormItem v-if="props.form.vlessEncryptionEnabled" label="客户端公钥">
<ElInput v-model="props.form.vlessEncryption" placeholder="encryption key" />
</ElFormItem>
<ElFormItem v-if="props.form.vlessEncryptionEnabled" label="服务端私钥">
<ElInput v-model="props.form.vlessDecryption" placeholder="decryption key" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'hysteria'">
<ElFormItem label="协议版本">
<ElSelect v-model="props.form.hysteriaVersion" placeholder="请选择版本">
<ElOption :value="1" label="Hysteria 1" />
<ElOption :value="2" label="Hysteria 2" />
</ElSelect>
</ElFormItem>
<ElFormItem label="上行带宽 Mbps">
<ElInputNumber v-model="props.form.hysteriaUpMbps" :min="0" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="下行带宽 Mbps">
<ElInputNumber v-model="props.form.hysteriaDownMbps" :min="0" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="端口跳跃间隔(秒)">
<ElInputNumber v-model="props.form.hysteriaHopInterval" :min="0" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="启用混淆" class="form-grid--full">
<div class="switch-row">
<div>
<strong>Obfs</strong>
<span>Hysteria 2 默认推荐 Salamander开启后需提供密码</span>
</div>
<ElSwitch v-model="props.form.hysteriaObfsEnabled" />
</div>
</ElFormItem>
<ElFormItem v-if="props.form.hysteriaObfsEnabled" label="混淆类型">
<ElInput v-model="props.form.hysteriaObfsType" placeholder="salamander" />
</ElFormItem>
<ElFormItem v-if="props.form.hysteriaObfsEnabled" label="混淆密码">
<ElInput v-model="props.form.hysteriaObfsPassword" placeholder="请输入混淆密码" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'tuic'">
<ElFormItem label="协议版本">
<ElInputNumber v-model="props.form.tuicVersion" :min="1" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="拥塞控制">
<ElSelect v-model="props.form.tuicCongestionControl" placeholder="请选择拥塞控制">
<ElOption
v-for="option in NODE_CONGESTION_CONTROL_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="UDP Relay Mode">
<ElSelect v-model="props.form.tuicUdpRelayMode" placeholder="请选择模式">
<ElOption
v-for="option in NODE_UDP_RELAY_MODE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="ALPN">
<ElSelect
v-model="props.form.tuicAlpn"
multiple
filterable
allow-create
default-first-option
placeholder="输入后回车添加 ALPN"
/>
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'mieru'">
<ElFormItem label="传输方式">
<ElSelect v-model="props.form.mieruTransport" placeholder="请选择传输方式">
<ElOption value="TCP" label="TCP" />
<ElOption value="UDP" label="UDP" />
</ElSelect>
</ElFormItem>
<ElFormItem label="Traffic Pattern">
<ElInput v-model="props.form.mieruTrafficPattern" placeholder="例如:steady" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'anytls'">
<ElFormItem label="Padding Scheme" class="form-grid--full">
<ElInput
v-model="props.form.anytlsPaddingSchemeText"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="每行一条 padding scheme"
/>
</ElFormItem>
</template>
</div>
</section>
<section v-if="showMultiplexSection" class="form-section">
<div class="section-head">
<div>
<h3>多路复用</h3>
<p>对支持的协议开放多路复用与 Brutal 加速配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="启用多路复用" class="form-grid--full">
<div class="switch-row">
<div>
<strong>Multiplex</strong>
<span>适用于 VLess / VMess / Trojan / Mieru 的复用场景</span>
</div>
<ElSwitch v-model="props.form.multiplexEnabled" />
</div>
</ElFormItem>
<template v-if="props.form.multiplexEnabled">
<ElFormItem label="复用协议">
<ElSelect v-model="props.form.multiplexProtocol" placeholder="请选择协议">
<ElOption
v-for="option in NODE_MUX_PROTOCOL_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="最大连接数">
<ElInputNumber
v-model="props.form.multiplexMaxConnections"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="填充">
<ElSwitch v-model="props.form.multiplexPadding" />
</ElFormItem>
<ElFormItem label="启用 Brutal">
<ElSwitch v-model="props.form.multiplexBrutalEnabled" />
</ElFormItem>
<template v-if="props.form.multiplexBrutalEnabled">
<ElFormItem label="Brutal 上行 Mbps">
<ElInputNumber
v-model="props.form.multiplexBrutalUpMbps"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="Brutal 下行 Mbps">
<ElInputNumber
v-model="props.form.multiplexBrutalDownMbps"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
</template>
</template>
</div>
</section>
</template>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { saveServerGroup } from '@/api/admin'
import type { AdminServerGroupItem } from '@/types/api'
type DialogMode = 'create' | 'edit'
interface NodeGroupFormModel {
name: string
}
const props = defineProps<{
visible: boolean
mode: DialogMode
group: AdminServerGroupItem | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeGroupFormModel>({
name: '',
})
const dialogTitle = computed(() => props.mode === 'create' ? '添加权限组' : '编辑权限组')
const dialogDescription = computed(() => props.mode === 'create'
? '创建新的权限组,供节点、套餐与用户权限分配使用。'
: '修改权限组信息,更新后会立即影响后台显示。')
const rules = computed<FormRules<NodeGroupFormModel>>(() => ({
name: [{ required: true, message: '请输入权限组名称', trigger: 'blur' }],
}))
function resetForm() {
form.name = ''
}
function closeDialog() {
emit('update:visible', false)
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
submitting.value = true
try {
await saveServerGroup({
id: props.mode === 'edit' ? props.group?.id : undefined,
name: form.name.trim(),
})
ElMessage.success(props.mode === 'create' ? '权限组已创建' : '权限组已更新')
emit('success')
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限组保存失败')
} finally {
submitting.value = false
}
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
resetForm()
form.name = props.group?.name ?? ''
formRef.value?.clearValidate()
},
{ immediate: true },
)
</script>
<template>
<ElDialog
:model-value="props.visible"
:title="dialogTitle"
width="min(480px, calc(100vw - 32px))"
class="node-group-dialog"
destroy-on-close
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="dialog-shell">
<p class="dialog-description">{{ dialogDescription }}</p>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
>
<ElFormItem label="组名称" prop="name">
<ElInput
v-model="form.name"
maxlength="30"
show-word-limit
placeholder="请输入有意义的权限组名称"
/>
</ElFormItem>
</ElForm>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '创建' : '更新' }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.dialog-shell {
display: grid;
gap: 18px;
}
.dialog-description {
color: var(--xboard-text-muted);
line-height: 1.6;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
width: 100%;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
.node-groups-page {
display: grid;
gap: 20px;
}
.page-header {
display: grid;
gap: 10px;
}
.page-copy {
display: grid;
gap: 10px;
}
.page-copy h1 {
margin: 0;
font-size: clamp(34px, 4.8vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: var(--xboard-text-strong);
}
.page-copy p {
margin: 0;
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.page-summary,
.table-toolbar,
.toolbar-left,
.table-footer,
.action-group,
.metric-chip,
.metric-link {
display: flex;
align-items: center;
gap: 10px;
}
.page-summary {
flex-wrap: wrap;
}
.page-summary span {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
border-radius: 999px;
background: rgba(0, 113, 227, 0.06);
color: var(--xboard-text-secondary);
font-size: 13px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-left {
flex-wrap: wrap;
}
.toolbar-search {
width: min(320px, 100%);
}
.node-groups-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.node-groups-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.name-cell {
display: grid;
gap: 6px;
}
.name-cell strong {
color: var(--xboard-text-strong);
}
.name-cell span,
.table-footer span,
.metric-chip.is-muted {
color: var(--xboard-text-muted);
}
.metric-chip,
.metric-link {
font-variant-numeric: tabular-nums;
}
.metric-chip {
color: var(--xboard-text-secondary);
}
.metric-link {
padding: 0;
height: auto;
}
.action-btn {
font-size: 18px;
}
.danger-btn {
color: var(--xboard-danger);
}
.table-empty {
padding: 24px 0;
}
@media (max-width: 1080px) {
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
}
+242 -86
View File
@@ -1,100 +1,256 @@
<script setup lang="ts">
const milestones = [
'接入权限组列表与用户 / 节点引用统计',
'补齐新增、编辑、删除与使用冲突提示',
'联动节点页的权限组筛选与维护闭环',
]
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
Delete,
EditPen,
Plus,
Search,
User,
} from '@element-plus/icons-vue'
import {
deleteServerGroup,
getServerGroups,
} from '@/api/admin'
import type { AdminServerGroupItem } from '@/types/api'
import {
filterNodeGroups,
normalizeNodeGroup,
summarizeNodeGroups,
} from '@/utils/nodeGroups'
import NodeGroupEditorDialog from './NodeGroupEditorDialog.vue'
type DialogMode = 'create' | 'edit'
const router = useRouter()
const loading = ref(true)
const errorMessage = ref('')
const keyword = ref('')
const current = ref(1)
const pageSize = ref(10)
const groups = ref<AdminServerGroupItem[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<DialogMode>('create')
const activeGroup = ref<AdminServerGroupItem | null>(null)
const filteredGroups = computed(() => filterNodeGroups(groups.value, keyword.value))
const visibleGroups = computed(() => {
const start = (current.value - 1) * pageSize.value
return filteredGroups.value.slice(start, start + pageSize.value)
})
const summary = computed(() => summarizeNodeGroups(groups.value))
async function loadPage() {
loading.value = true
errorMessage.value = ''
try {
const response = await getServerGroups()
groups.value = (response.data ?? []).map((item) => normalizeNodeGroup(item))
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '权限组数据加载失败'
} finally {
loading.value = false
}
}
function openCreateDialog() {
dialogMode.value = 'create'
activeGroup.value = null
dialogVisible.value = true
}
function openEditDialog(group: AdminServerGroupItem) {
dialogMode.value = 'edit'
activeGroup.value = group
dialogVisible.value = true
}
function handleDialogSuccess() {
void loadPage()
}
async function handleDelete(group: AdminServerGroupItem) {
try {
await ElMessageBox.confirm(
`删除权限组「${group.name}」后无法恢复,确认继续吗?`,
'删除权限组',
{ type: 'warning' },
)
await deleteServerGroup(group.id)
ElMessage.success('权限组已删除')
await loadPage()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '权限组删除失败')
}
}
function openNodeFilter(group: AdminServerGroupItem) {
void router.push({
path: '/nodes',
query: { group: String(group.id) },
})
}
watch(keyword, () => {
current.value = 1
})
watch(filteredGroups, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
if (current.value > maxPage) {
current.value = maxPage
}
})
watch(pageSize, () => {
current.value = 1
})
onMounted(() => {
void loadPage()
})
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">Node Groups</p>
<div class="node-groups-page">
<section class="page-header">
<div class="page-copy">
<h1>权限组管理</h1>
<span>入口已预留本轮先完成节点列表主链路下一阶段继续接入权限组的真实维护能力</span>
<p>管理所有权限组包括添加删除编辑等操作</p>
<div class="page-summary">
<span> {{ groups.length }} </span>
<span>关联用户 {{ summary.totalUsers }}</span>
<span>关联节点 {{ summary.totalServers }}</span>
</div>
</div>
</section>
<section class="placeholder-card">
<header>
<h2>下一阶段计划</h2>
<p>这一页不会空着结束而是明确告诉你后续要接什么</p>
<section class="table-shell">
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
:title="errorMessage"
>
<template #default>
<ElButton size="small" @click="loadPage">重新加载</ElButton>
</template>
</ElAlert>
<header class="table-toolbar">
<div class="toolbar-left">
<ElButton type="primary" @click="openCreateDialog">
<ElIcon><Plus /></ElIcon>
添加权限组
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索权限组..."
class="toolbar-search"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
</header>
<ol>
<li v-for="item in milestones" :key="item">{{ item }}</li>
</ol>
<ElTable
:data="visibleGroups"
v-loading="loading"
row-key="id"
class="node-groups-table"
empty-text="当前筛选条件下暂无权限组"
>
<ElTableColumn prop="id" label="组ID" width="104" />
<ElTableColumn label="组名称" min-width="280">
<template #default="{ row }">
<div class="name-cell">
<strong>{{ row.name }}</strong>
<span>用于节点套餐与用户的权限范围归属</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="用户数量" width="160">
<template #default="{ row }">
<span class="metric-chip">
<ElIcon><User /></ElIcon>
{{ row.users_count ?? 0 }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="节点数量" width="180">
<template #default="{ row }">
<ElButton
v-if="Number(row.server_count ?? 0) > 0"
link
type="primary"
class="metric-link"
@click="openNodeFilter(row)"
>
<ElIcon><Connection /></ElIcon>
{{ row.server_count }}
</ElButton>
<span v-else class="metric-chip is-muted">
<ElIcon><Connection /></ElIcon>
0
</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text class="action-btn" @click="openEditDialog(row)">
<ElIcon><EditPen /></ElIcon>
</ElButton>
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
<template #empty>
<div class="table-empty">
<ElEmpty :description="keyword ? '当前搜索条件下暂无权限组。' : '暂无权限组数据。'">
<ElButton v-if="keyword" @click="keyword = ''">清空搜索</ElButton>
<ElButton v-else @click="loadPage">重新加载</ElButton>
</ElEmpty>
</div>
</template>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ filteredGroups.length }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
:total="filteredGroups.length"
background
/>
</footer>
</section>
<NodeGroupEditorDialog
v-model:visible="dialogVisible"
:mode="dialogMode"
:group="activeGroup"
@success="handleDialogSuccess"
/>
</div>
</template>
<style scoped>
.placeholder-page {
display: grid;
gap: 24px;
}
.placeholder-hero {
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.placeholder-copy {
display: grid;
gap: 10px;
max-width: 720px;
}
.placeholder-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.placeholder-copy h1 {
font-size: clamp(34px, 5vw, 48px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.placeholder-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.placeholder-card {
display: grid;
gap: 18px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.placeholder-card header {
display: grid;
gap: 8px;
}
.placeholder-card h2 {
font-size: 28px;
line-height: 1.1;
color: var(--xboard-text-strong);
}
.placeholder-card p,
.placeholder-card li {
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.placeholder-card ol {
display: grid;
gap: 12px;
padding-left: 20px;
}
</style>
<style scoped lang="scss" src="./NodeGroupsView.scss"></style>
@@ -0,0 +1,77 @@
.dialog-shell,
.dialog-form {
display: grid;
gap: 20px;
}
.dialog-copy {
display: grid;
gap: 4px;
}
.dialog-copy p {
margin: 0;
font-size: 12px;
color: var(--xboard-text-muted);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.dialog-copy h2 {
margin: 0;
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.dialog-copy span,
.field-help span {
color: var(--xboard-text-secondary);
line-height: 1.47;
}
.dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field-help {
margin-top: 8px;
}
.action-panel {
display: grid;
gap: 8px;
padding: 18px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.action-panel__main,
.dialog-footer {
display: flex;
align-items: center;
gap: 10px;
}
.action-panel strong {
color: var(--xboard-text-strong);
}
.action-panel span {
color: var(--xboard-text-muted);
line-height: 1.5;
}
.dialog-footer {
justify-content: flex-end;
width: 100%;
}
@media (max-width: 767px) {
.dialog-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { saveNodeRoute } from '@/api/admin'
import type { AdminNodeRouteItem } from '@/types/api'
import {
createEmptyNodeRouteForm,
getNodeRouteActionMeta,
getNodeRouteActionValueLabel,
getNodeRouteActionValuePlaceholder,
NODE_ROUTE_ACTION_OPTIONS,
parseRouteMatchLines,
requiresNodeRouteActionValue,
toNodeRouteFormModel,
toNodeRouteSavePayload,
type NodeRouteFormModel,
} from '@/utils/routes'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
route?: AdminNodeRouteItem | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeRouteFormModel>(createEmptyNodeRouteForm())
const dialogTitle = computed(() => props.mode === 'create' ? '添加路由' : '编辑路由')
const needsActionValue = computed(() => requiresNodeRouteActionValue(form.action))
const actionMeta = computed(() => getNodeRouteActionMeta(form.action))
function closeDialog() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toNodeRouteFormModel(props.route))
if (!needsActionValue.value) {
form.actionValue = ''
}
}
function validateMatchText(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (parseRouteMatchLines(value).length === 0) {
callback(new Error('请至少输入一条匹配规则'))
return
}
callback()
}
function validateActionValue(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (needsActionValue.value && !value.trim()) {
callback(new Error(`请输入${getNodeRouteActionValueLabel(form.action)}`))
return
}
callback()
}
const rules = computed<FormRules<NodeRouteFormModel>>(() => ({
remarks: [{ required: true, message: '请输入备注', trigger: 'blur' }],
matchText: [{ validator: validateMatchText, trigger: 'blur' }],
action: [{ required: true, message: '请选择动作', trigger: 'change' }],
actionValue: [{ validator: validateActionValue, trigger: 'blur' }],
}))
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
submitting.value = true
try {
await saveNodeRoute(toNodeRouteSavePayload(form))
const message = props.mode === 'create' ? '路由已创建' : '路由已更新'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '路由保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.route, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
nextTick(() => {
formRef.value?.clearValidate()
})
},
{ immediate: true },
)
watch(
() => form.action,
() => {
if (!needsActionValue.value) {
form.actionValue = ''
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
:title="dialogTitle"
width="min(640px, calc(100vw - 32px))"
destroy-on-close
class="node-route-editor-dialog"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="dialog-shell">
<div class="dialog-copy">
<p>Node Routes</p>
<h2>{{ dialogTitle }}</h2>
<span>维护路由备注匹配规则与动作配置保存后会同步到节点侧使用的路由规则</span>
</div>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="dialog-form"
>
<ElFormItem label="备注" prop="remarks">
<ElInput
v-model="form.remarks"
placeholder="例如:屏蔽广告、走指定 DNS"
maxlength="80"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="匹配规则" prop="matchText">
<ElInput
v-model="form.matchText"
type="textarea"
:autosize="{ minRows: 5, maxRows: 8 }"
placeholder="每行一条规则,例如:&#10;test.com&#10;*.apple.com"
/>
<div class="field-help">
<span>每行一条规则保存时会自动去空与去重</span>
</div>
</ElFormItem>
<div class="dialog-grid">
<ElFormItem label="动作" prop="action">
<ElSelect v-model="form.action" placeholder="请选择动作">
<ElOption
v-for="option in NODE_ROUTE_ACTION_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="needsActionValue"
:label="getNodeRouteActionValueLabel(form.action)"
prop="actionValue"
>
<ElInput
v-model="form.actionValue"
:placeholder="getNodeRouteActionValuePlaceholder(form.action)"
/>
</ElFormItem>
</div>
<section class="action-panel">
<div class="action-panel__main">
<strong>当前动作</strong>
<ElTag round effect="plain" :type="actionMeta.tagType">
{{ actionMeta.label }}
</ElTag>
</div>
<span v-if="needsActionValue">
{{ getNodeRouteActionValueLabel(form.action) }} 会随当前路由一起下发到节点端
</span>
<span v-else>
当前动作不需要额外动作值保存后会直接按策略执行
</span>
</section>
</ElForm>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss" src="./NodeRouteEditorDialog.scss"></style>
+208
View File
@@ -0,0 +1,208 @@
.node-routes-page {
display: grid;
gap: 24px;
}
.node-routes-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 34px;
border-radius: 28px;
background: #000000;
}
.hero-copy {
display: grid;
gap: 12px;
max-width: 640px;
}
.hero-kicker {
margin: 0;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 0;
color: #ffffff;
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
}
.hero-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 360px;
}
.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: 20px;
line-height: 1.2;
}
.table-shell {
display: grid;
gap: 18px;
padding: 22px 22px 18px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-left,
.table-footer,
.footer-right,
.footer-hint,
.action-group {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-search {
width: min(280px, 100%);
}
.table-alert {
border-radius: 16px;
}
.routes-table :deep(.el-table__cell) {
vertical-align: top;
}
.routes-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.remark-cell,
.value-cell {
display: grid;
gap: 8px;
padding: 2px 0;
}
.remark-cell strong,
.value-cell strong {
color: var(--xboard-text-strong);
line-height: 1.35;
}
.remark-cell span,
.value-cell span,
.table-footer > span,
.footer-hint span {
color: var(--xboard-text-muted);
line-height: 1.5;
}
.id-tag,
.action-tag {
font-variant-numeric: tabular-nums;
}
.action-tag--dns {
color: #0071e3;
border-color: rgba(0, 113, 227, 0.18);
background: rgba(0, 113, 227, 0.08);
}
.action-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 10px;
color: var(--xboard-text-secondary);
}
.action-btn:hover {
color: #0071e3;
background: rgba(0, 113, 227, 0.08);
}
.danger-btn:hover {
color: #d92d20;
background: rgba(217, 45, 32, 0.08);
}
.table-empty {
padding: 24px 0;
}
.footer-right {
justify-content: flex-end;
flex-wrap: wrap;
}
.footer-hint {
color: var(--xboard-text-muted);
}
@media (max-width: 1180px) {
.node-routes-hero,
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
}
.footer-right {
justify-content: space-between;
}
}
@media (max-width: 767px) {
.hero-stats {
grid-template-columns: 1fr;
}
.toolbar-left,
.footer-right {
flex-direction: column;
align-items: stretch;
}
.action-group {
justify-content: flex-start;
}
.footer-hint {
align-items: flex-start;
}
}
+285 -86
View File
@@ -1,100 +1,299 @@
<script setup lang="ts">
const milestones = [
'接入路由规则列表、动作类型与备注字段',
'补齐新增 / 编辑 / 删除路由的操作台',
'与节点页建立路由引用可视化关系,方便运营判断影响面',
]
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
Delete,
EditPen,
Plus,
RefreshRight,
Search,
} from '@element-plus/icons-vue'
import {
deleteNodeRoute,
fetchNodeRoutes,
fetchNodes,
} from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem } from '@/types/api'
import NodeRouteEditorDialog from './NodeRouteEditorDialog.vue'
import {
buildNodeRouteReferenceMap,
countReferencedNodeRoutes,
filterNodeRoutes,
formatNodeRouteActionValue,
getNodeRouteActionMeta,
normalizeNodeRoute,
} from '@/utils/routes'
type DialogMode = 'create' | 'edit'
const loading = ref(false)
const errorMessage = ref('')
const editorVisible = ref(false)
const editorMode = ref<DialogMode>('create')
const activeRoute = ref<AdminNodeRouteItem | null>(null)
const deletingId = ref<number | null>(null)
const keyword = ref('')
const current = ref(1)
const pageSize = ref(10)
const routes = ref<AdminNodeRouteItem[]>([])
const nodes = ref<AdminNodeItem[]>([])
const referenceMap = computed(() => buildNodeRouteReferenceMap(nodes.value))
const filteredRoutes = computed(() => filterNodeRoutes(routes.value, keyword.value, referenceMap.value))
const visibleRoutes = computed(() => {
const start = (current.value - 1) * pageSize.value
return filteredRoutes.value.slice(start, start + pageSize.value)
})
const heroStats = computed(() => [
{ label: '路由总数', value: String(routes.value.length) },
{ label: '禁止访问', value: String(routes.value.filter((item) => item.action === 'block').length) },
{ label: 'DNS 解析', value: String(routes.value.filter((item) => item.action === 'dns').length) },
{ label: '已被引用', value: String(countReferencedNodeRoutes(routes.value, referenceMap.value)) },
])
const hasActiveFilters = computed(() => keyword.value.trim() !== '')
function isDeleting(id: number): boolean {
return deletingId.value === id
}
async function loadData() {
loading.value = true
errorMessage.value = ''
try {
const [routeResult, nodeResult] = await Promise.all([
fetchNodeRoutes(),
fetchNodes(),
])
routes.value = (routeResult.data ?? [])
.map((route) => normalizeNodeRoute(route))
.sort((a, b) => a.id - b.id)
nodes.value = nodeResult.data ?? []
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '路由数据加载失败'
} finally {
loading.value = false
}
}
function openCreateDialog() {
editorMode.value = 'create'
activeRoute.value = null
editorVisible.value = true
}
function openEditDialog(route: AdminNodeRouteItem) {
editorMode.value = 'edit'
activeRoute.value = route
editorVisible.value = true
}
function handleReset() {
keyword.value = ''
}
async function handleDelete(route: AdminNodeRouteItem) {
deletingId.value = route.id
try {
await ElMessageBox.confirm(`删除路由「${route.remarks}」后无法恢复,确认继续吗?`, '删除路由', {
type: 'warning',
})
await deleteNodeRoute(route.id)
ElMessage.success('路由已删除')
await loadData()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '路由删除失败')
} finally {
deletingId.value = null
}
}
watch([keyword, pageSize], () => {
current.value = 1
})
watch(filteredRoutes, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
if (current.value > maxPage) {
current.value = maxPage
}
})
onMounted(() => {
void loadData().catch((error) => {
ElMessage.error(error instanceof Error ? error.message : '路由管理页面初始化失败')
})
})
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">Node Routes</p>
<div class="node-routes-page">
<section class="node-routes-hero">
<div class="hero-copy">
<p class="hero-kicker">Node Routes</p>
<h1>路由管理</h1>
<span>侧边栏入口已对齐下一阶段将继续补齐路由规则列表与节点引用关系</span>
<span>管理所有路由规则包括添加删除编辑与节点引用摘要查看</span>
</div>
<div class="hero-stats">
<article v-for="item in heroStats" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="placeholder-card">
<header>
<h2>接下来会补什么</h2>
<p>本轮先把节点管理主链路落稳路由管理不留空白先把后续接入方向固定下来</p>
<section class="table-shell">
<header class="table-toolbar">
<div class="toolbar-left">
<ElButton type="primary" @click="openCreateDialog">
<ElIcon><Plus /></ElIcon>
添加路由
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索路由..."
class="toolbar-search"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
</header>
<ol>
<li v-for="item in milestones" :key="item">{{ item }}</li>
</ol>
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
class="table-alert"
:title="errorMessage"
>
<template #default>
<ElButton text @click="loadData">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
:data="visibleRoutes"
v-loading="loading"
class="routes-table"
row-key="id"
empty-text="当前筛选条件下暂无路由"
>
<ElTableColumn label="组ID" width="108">
<template #default="{ row }">
<ElTag round effect="plain" class="id-tag">
{{ row.id }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="320">
<template #default="{ row }">
<div class="remark-cell">
<strong>{{ row.remarks }}</strong>
<span v-if="referenceMap[row.id]?.count">
引用 {{ referenceMap[row.id].count }} 个节点 · {{ referenceMap[row.id].preview }}
</span>
<span v-else>未被节点引用</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="动作值" min-width="260">
<template #default="{ row }">
<div class="value-cell">
<strong>{{ formatNodeRouteActionValue(row) }}</strong>
<span>匹配 {{ row.match.length }} 条规则</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="动作" width="190">
<template #default="{ row }">
<ElTag
round
effect="plain"
:type="getNodeRouteActionMeta(row.action).tagType"
class="action-tag"
:class="`action-tag--${row.action}`"
>
{{ getNodeRouteActionMeta(row.action).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="110" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text class="action-btn" @click="openEditDialog(row)">
<ElIcon><EditPen /></ElIcon>
</ElButton>
<ElButton
text
class="action-btn danger-btn"
:loading="isDeleting(row.id)"
@click="handleDelete(row)"
>
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
<template #empty>
<div class="table-empty">
<ElEmpty
:description="hasActiveFilters ? '当前筛选条件下暂无路由。' : '暂无路由数据。'"
>
<ElButton v-if="hasActiveFilters" @click="handleReset">清空筛选</ElButton>
<ElButton v-else @click="loadData">
<ElIcon><RefreshRight /></ElIcon>
重新加载
</ElButton>
</ElEmpty>
</div>
</template>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ filteredRoutes.length }} </span>
<div class="footer-right">
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>节点引用摘要基于当前节点 `route_ids` 实时推导</span>
</div>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
:total="filteredRoutes.length"
background
/>
</div>
</footer>
</section>
<NodeRouteEditorDialog
v-model:visible="editorVisible"
:mode="editorMode"
:route="activeRoute"
@success="() => loadData()"
/>
</div>
</template>
<style scoped>
.placeholder-page {
display: grid;
gap: 24px;
}
.placeholder-hero {
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.placeholder-copy {
display: grid;
gap: 10px;
max-width: 720px;
}
.placeholder-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.placeholder-copy h1 {
font-size: clamp(34px, 5vw, 48px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.placeholder-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.placeholder-card {
display: grid;
gap: 18px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.placeholder-card header {
display: grid;
gap: 8px;
}
.placeholder-card h2 {
font-size: 28px;
line-height: 1.1;
color: var(--xboard-text-strong);
}
.placeholder-card p,
.placeholder-card li {
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.placeholder-card ol {
display: grid;
gap: 12px;
padding-left: 20px;
}
</style>
<style scoped lang="scss" src="./NodeRoutesView.scss"></style>
+65
View File
@@ -0,0 +1,65 @@
.sort-shell,
.sort-list {
display: grid;
gap: 16px;
}
.sort-copy,
.sort-meta span {
color: var(--xboard-text-muted);
line-height: 1.55;
}
.sort-item,
.sort-item__main,
.sort-actions,
.sort-footer {
display: flex;
align-items: center;
gap: 12px;
}
.sort-item,
.sort-footer {
justify-content: space-between;
}
.sort-item {
padding: 16px;
border-radius: 18px;
background: #fbfbfd;
}
.sort-index {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
font-weight: 600;
}
.sort-meta {
display: grid;
gap: 6px;
}
.sort-meta strong {
color: var(--xboard-text-strong);
}
@media (max-width: 767px) {
.sort-item,
.sort-item__main,
.sort-actions,
.sort-footer {
flex-direction: column;
align-items: stretch;
}
.sort-index {
align-self: flex-start;
}
}
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
import { sortNodes } from '@/api/admin'
import type { AdminNodeItem } from '@/types/api'
import { getNodeProtocolLabel, moveNodeOrder, sortNodesByOrder } from '@/utils/nodeEditor'
const props = defineProps<{
visible: boolean
nodes: AdminNodeItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const submitting = ref(false)
const draft = ref<AdminNodeItem[]>([])
const sortedDraft = computed(() => draft.value)
function closeDialog() {
emit('update:visible', false)
}
function moveDraft(index: number, direction: -1 | 1) {
draft.value = moveNodeOrder(draft.value, index, direction)
}
async function handleSubmit() {
submitting.value = true
try {
await sortNodes(
draft.value.map((item, index) => ({
id: item.id,
order: index + 1,
})),
)
const message = '节点排序已保存'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点排序保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.nodes],
([visible]) => {
if (!visible) {
return
}
draft.value = sortNodesByOrder(props.nodes).map((item) => ({ ...item }))
},
{ immediate: true },
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(720px, calc(100vw - 32px))"
class="node-sort-dialog"
title="编辑排序"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="sort-shell">
<p class="sort-copy">按照当前展示顺序调整节点排序保存后会同步到后台 `/server/manage/sort`</p>
<div class="sort-list">
<article
v-for="(item, index) in sortedDraft"
:key="item.id"
class="sort-item"
>
<div class="sort-item__main">
<span class="sort-index">{{ index + 1 }}</span>
<div class="sort-meta">
<strong>{{ item.name }}</strong>
<span>
{{ getNodeProtocolLabel(item.type) }}
· {{ item.host }}:{{ item.server_port || item.port }}
</span>
</div>
</div>
<div class="sort-actions">
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
<ElIcon><ArrowUp /></ElIcon>
上移
</ElButton>
<ElButton :disabled="index === sortedDraft.length - 1" @click="moveDraft(index, 1)">
<ElIcon><ArrowDown /></ElIcon>
下移
</ElButton>
</div>
</article>
</div>
</div>
<template #footer>
<div class="sort-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
保存排序
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss" src="./NodeSortDialog.scss"></style>
+88 -14
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
@@ -13,10 +14,13 @@ import {
copyNode,
deleteNode,
fetchNodes,
fetchNodeRoutes,
getServerGroups,
updateNode,
} from '@/api/admin'
import type { AdminNodeItem, AdminServerGroupItem } from '@/types/api'
import type { AdminNodeItem, AdminNodeRouteItem, AdminServerGroupItem } from '@/types/api'
import NodeEditorDialog from './NodeEditorDialog.vue'
import NodeSortDialog from './NodeSortDialog.vue'
import {
buildNodeTypeOptions,
countOnlineNodes,
@@ -29,25 +33,34 @@ import {
getNodeStatusMeta,
getNodeTypeLabel,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
type NodeAction = 'edit' | 'copy' | 'delete'
type NodeDialogMode = 'create' | 'edit'
const route = useRoute()
const router = useRouter()
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 switchingIds = 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 filteredNodes = computed(() => filterNodes(
const filteredNodes = computed(() => sortNodesByOrder(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')
@@ -59,6 +72,24 @@ const summaryCards = computed(() => [
{ label: '当前结果', value: String(filteredNodes.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'
}
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
if (pending) {
if (!list.value.includes(id)) {
@@ -78,8 +109,20 @@ function isWorking(id: number): boolean {
return workingIds.value.includes(id)
}
function notifyPending(scope: string) {
ElMessage.info(`${scope} 会在下一阶段接入,本轮已先打通节点列表主链路。`)
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
}
async function loadNodeBoard() {
@@ -87,13 +130,16 @@ async function loadNodeBoard() {
errorMessage.value = ''
try {
const [nodesResponse, groupsResponse] = await Promise.all([
const [nodesResponse, groupsResponse, routesResponse] = await Promise.all([
fetchNodes(),
getServerGroups(),
fetchNodeRoutes(),
])
nodes.value = nodesResponse.data ?? []
nodes.value = sortNodesByOrder(nodesResponse.data ?? [])
groups.value = groupsResponse.data ?? []
routes.value = routesResponse.data ?? []
applyRouteGroupFilter()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
} finally {
@@ -107,6 +153,10 @@ function handleReset() {
groupFilter.value = 'all'
}
function openNodeGroupManagement() {
void router.push('/node-groups')
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
@@ -132,7 +182,7 @@ async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
async function handleAction(action: NodeAction, node: AdminNodeItem) {
if (action === 'edit') {
notifyPending(`编辑节点 #${node.id}`)
openEditEditor(node)
return
}
@@ -169,6 +219,13 @@ async function handleAction(action: NodeAction, node: AdminNodeItem) {
onMounted(() => {
void loadNodeBoard()
})
watch(
() => route.query.group,
() => {
applyRouteGroupFilter()
},
)
</script>
<template>
@@ -193,7 +250,7 @@ onMounted(() => {
<section class="nodes-board">
<header class="board-toolbar">
<div class="toolbar-fields">
<ElButton type="primary" @click="notifyPending('添加节点')">
<ElButton type="primary" @click="openCreateEditor">
<ElIcon><Plus /></ElIcon>
添加节点
</ElButton>
@@ -231,11 +288,12 @@ onMounted(() => {
</div>
<div class="toolbar-actions">
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton @click="notifyPending('编辑排序')">编辑排序</ElButton>
<ElButton @click="openSortEditor">编辑排序</ElButton>
</div>
</header>
@@ -362,8 +420,8 @@ onMounted(() => {
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点下一阶段</ElDropdownItem>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
</ElDropdownMenu>
@@ -391,10 +449,26 @@ onMounted(() => {
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>完整的节点创建编辑与排序流程将在下一阶段补齐</span>
<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()"
/>
</div>
</template>