feat(admin-frontend): 完成节点与礼品卡管理工作台
补齐节点管理真实新增、编辑与排序流程,接入权限组与路由组 维护页,并支持 11 种协议的动态配置表单 开放礼品卡管理入口,交付模板、兑换码、使用记录与统计四页签 工作台,接入 gift-card 相关后台接口 将知识库、权限组与路由管理从占位页升级为真实页面,并修复侧边栏 低高度裁切问题 修复仪表盘 24h 流量排行涨跌始终为 0 的问题,改为对比昨天整日统 计并补充单元测试
This commit is contained in:
@@ -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,例如: /api /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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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="每行一条规则,例如: test.com *.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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user