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
@@ -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>