mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 6-iii — xray Routing tab + rule modal
Replaces the Routing tab placeholder with a full editor for templateSettings.routing.rules: - RoutingTab.vue: a-table over the parsed rules with the legacy six- column layout (action / source / network / destination / inbound / outbound) and the same "lead value + N more" pill renderer for multi-value criteria. Mobile drops source/network/destination for readability. Per-row dropdown handles edit / move-up / move-down / delete; the array-mutation reordering replaces the legacy jQuery Sortable drag handle without pulling in a sortable lib. - RuleFormModal.vue: full form mirroring xray_rule_modal.html — CSV inputs for sourceIP/sourcePort/vlessRoute/ip/domain/user/port, Network select, Protocol multi-select, Attrs key/value pairs, inbound-tag multi-select sourced from templateSettings.inbounds + parent inboundTags + dnsTag, outbound-tag single-select sourced from templateSettings.outbounds + clientReverseTags, and balancerTag from templateSettings.routing.balancers. Submit serializes via the same shape the legacy `getResult` produces (CSV → array, drop empty fields). - XrayPage.vue: imports RoutingTab and exposes inboundTags + clientReverseTags from useXraySetting so the modal can populate its tag pools. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c20dd42d7a
commit
57f502525f
3 changed files with 664 additions and 1 deletions
401
frontend/src/pages/xray/RoutingTab.vue
Normal file
401
frontend/src/pages/xray/RoutingTab.vue
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import RuleFormModal from './RuleFormModal.vue';
|
||||||
|
|
||||||
|
// Routing tab — table over templateSettings.routing.rules with the
|
||||||
|
// modernised legacy column layout. Each row is rendered as a single
|
||||||
|
// "lead value + N more" pill per criterion (matches the legacy pill
|
||||||
|
// layout); full lists surface via tooltip on hover.
|
||||||
|
//
|
||||||
|
// Reorder uses up/down buttons in the action menu rather than the
|
||||||
|
// jQuery-Sortable drag handle the legacy panel used — same effect,
|
||||||
|
// no extra dep. The mobile column layout drops source/network/
|
||||||
|
// destination criteria for readability.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templateSettings: { type: Object, default: null },
|
||||||
|
inboundTags: { type: Array, default: () => [] },
|
||||||
|
clientReverseTags: { type: Array, default: () => [] },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Table data — match the legacy routingRuleData shape ============
|
||||||
|
// Convert array criteria to CSV strings so the pill renderer can
|
||||||
|
// split + summarise them without needing a separate path per shape.
|
||||||
|
const rows = computed(() => {
|
||||||
|
if (!props.templateSettings?.routing?.rules) return [];
|
||||||
|
return props.templateSettings.routing.rules.map((rule, idx) => {
|
||||||
|
const r = { key: idx, ...rule };
|
||||||
|
if (Array.isArray(r.domain)) r.domain = r.domain.join(',');
|
||||||
|
if (Array.isArray(r.ip)) r.ip = r.ip.join(',');
|
||||||
|
if (Array.isArray(r.source)) r.source = r.source.join(',');
|
||||||
|
if (Array.isArray(r.user)) r.user = r.user.join(',');
|
||||||
|
if (Array.isArray(r.inboundTag)) r.inboundTag = r.inboundTag.join(',');
|
||||||
|
if (Array.isArray(r.protocol)) r.protocol = r.protocol.join(',');
|
||||||
|
if (r.attrs && typeof r.attrs === 'object' && !Array.isArray(r.attrs)) {
|
||||||
|
r.attrs = JSON.stringify(r.attrs, null, 2);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function csv(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Modal state ====================================================
|
||||||
|
const ruleModalOpen = ref(false);
|
||||||
|
const editingRule = ref(null);
|
||||||
|
const editingIndex = ref(null);
|
||||||
|
|
||||||
|
const inboundTagOptions = computed(() => {
|
||||||
|
const out = new Set();
|
||||||
|
for (const ib of props.templateSettings?.inbounds || []) {
|
||||||
|
if (ib.tag) out.add(ib.tag);
|
||||||
|
}
|
||||||
|
for (const t of props.inboundTags || []) out.add(t);
|
||||||
|
// dnsTag if DNS is configured.
|
||||||
|
const dt = props.templateSettings?.dns?.tag;
|
||||||
|
if (dt) out.add(dt);
|
||||||
|
return [...out];
|
||||||
|
});
|
||||||
|
|
||||||
|
const outboundTagOptions = computed(() => {
|
||||||
|
const out = new Set(['']);
|
||||||
|
for (const ob of props.templateSettings?.outbounds || []) {
|
||||||
|
if (ob.tag) out.add(ob.tag);
|
||||||
|
}
|
||||||
|
for (const t of props.clientReverseTags || []) {
|
||||||
|
if (t) out.add(t);
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
});
|
||||||
|
|
||||||
|
const balancerTagOptions = computed(() => {
|
||||||
|
const out = [''];
|
||||||
|
for (const b of props.templateSettings?.routing?.balancers || []) {
|
||||||
|
if (b.tag) out.push(b.tag);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
editingRule.value = null;
|
||||||
|
editingIndex.value = null;
|
||||||
|
ruleModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(idx) {
|
||||||
|
editingRule.value = props.templateSettings.routing.rules[idx];
|
||||||
|
editingIndex.value = idx;
|
||||||
|
ruleModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRuleConfirm(rule) {
|
||||||
|
// Empty submit (e.g. user clears every field) collapses to an
|
||||||
|
// object with only `type: "field"`. Match legacy: skip the write
|
||||||
|
// when the result is essentially empty.
|
||||||
|
if (JSON.stringify(rule).length <= 3) {
|
||||||
|
ruleModalOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editingIndex.value == null) {
|
||||||
|
props.templateSettings.routing.rules.push(rule);
|
||||||
|
} else {
|
||||||
|
props.templateSettings.routing.rules[editingIndex.value] = rule;
|
||||||
|
}
|
||||||
|
ruleModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(idx) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete rule #${idx + 1}?`,
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: () => props.templateSettings.routing.rules.splice(idx, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveUp(idx) {
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const rules = props.templateSettings.routing.rules;
|
||||||
|
[rules[idx - 1], rules[idx]] = [rules[idx], rules[idx - 1]];
|
||||||
|
}
|
||||||
|
function moveDown(idx) {
|
||||||
|
const rules = props.templateSettings.routing.rules;
|
||||||
|
if (idx >= rules.length - 1) return;
|
||||||
|
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Columns =========================================================
|
||||||
|
const desktopColumns = [
|
||||||
|
{ title: '#', align: 'center', width: 70, key: 'action' },
|
||||||
|
{ title: 'Source', align: 'left', width: 180, key: 'source' },
|
||||||
|
{ title: 'Network', align: 'left', width: 180, key: 'network' },
|
||||||
|
{ title: 'Destination', align: 'left', key: 'destination' },
|
||||||
|
{ title: 'Inbound', align: 'left', width: 180, key: 'inbound' },
|
||||||
|
{ title: 'Outbound', align: 'left', width: 170, key: 'target' },
|
||||||
|
];
|
||||||
|
const mobileColumns = [
|
||||||
|
{ title: '#', align: 'center', width: 70, key: 'action' },
|
||||||
|
{ title: 'Inbound', align: 'left', key: 'inbound' },
|
||||||
|
{ title: 'Outbound', align: 'left', width: 140, key: 'target' },
|
||||||
|
];
|
||||||
|
const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||||
|
<a-button type="primary" @click="openAdd">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
Add rule
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:row-key="(r) => r.key"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="isMobile ? {} : { x: 1000 }"
|
||||||
|
size="small"
|
||||||
|
class="routing-table"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<!-- ============== # / actions ============== -->
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<div class="action-cell">
|
||||||
|
<span class="row-index">{{ index + 1 }}</span>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button shape="circle" size="small">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item @click="openEdit(index)">
|
||||||
|
<EditOutlined /> Edit
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
|
<ArrowUpOutlined /> Move up
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
|
<ArrowDownOutlined /> Move down
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Source ============== -->
|
||||||
|
<template v-else-if="column.key === 'source'">
|
||||||
|
<div class="criterion-flow">
|
||||||
|
<a-tooltip v-if="record.sourceIP" :title="`Source IP: ${record.sourceIP}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">IP</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
||||||
|
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">Port</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.sourcePort)[0] }}</span>
|
||||||
|
<span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.vlessRoute" :title="`VLESS route: ${record.vlessRoute}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">VLESS</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.vlessRoute)[0] }}</span>
|
||||||
|
<span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!record.sourceIP && !record.sourcePort && !record.vlessRoute" class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Network ============== -->
|
||||||
|
<template v-else-if="column.key === 'network'">
|
||||||
|
<div class="criterion-flow">
|
||||||
|
<a-tooltip v-if="record.network" :title="`L4: ${record.network}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">L4</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
||||||
|
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">Protocol</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
||||||
|
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">Attrs</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.attrs)[0] }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!record.network && !record.protocol && !record.attrs" class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Destination ============== -->
|
||||||
|
<template v-else-if="column.key === 'destination'">
|
||||||
|
<div class="criterion-flow">
|
||||||
|
<a-tooltip v-if="record.ip" :title="`Destination IP: ${record.ip}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">IP</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.ip)[0] }}</span>
|
||||||
|
<span v-if="csv(record.ip).length > 1" class="criterion-more">+{{ csv(record.ip).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.domain" :title="`Domain: ${record.domain}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">Domain</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
||||||
|
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">Port</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
||||||
|
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Inbound ============== -->
|
||||||
|
<template v-else-if="column.key === 'inbound'">
|
||||||
|
<div class="criterion-flow">
|
||||||
|
<a-tooltip v-if="record.inboundTag" :title="`Inbound tag: ${record.inboundTag}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">Tag</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.inboundTag)[0] }}</span>
|
||||||
|
<span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="record.user" :title="`User: ${record.user}`">
|
||||||
|
<span class="criterion-row">
|
||||||
|
<span class="criterion-label">User</span>
|
||||||
|
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
||||||
|
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1 }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Outbound / balancer target ============== -->
|
||||||
|
<template v-else-if="column.key === 'target'">
|
||||||
|
<div class="target-cell">
|
||||||
|
<div v-if="record.outboundTag" class="target-row">
|
||||||
|
<ExportOutlined class="target-icon" />
|
||||||
|
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="record.balancerTag" class="target-row">
|
||||||
|
<ClusterOutlined class="target-icon" />
|
||||||
|
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<RuleFormModal
|
||||||
|
v-model:open="ruleModalOpen"
|
||||||
|
:rule="editingRule"
|
||||||
|
:inbound-tags="inboundTagOptions"
|
||||||
|
:outbound-tags="outboundTagOptions"
|
||||||
|
:balancer-tags="balancerTagOptions"
|
||||||
|
@confirm="onRuleConfirm"
|
||||||
|
/>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.row-index {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.criterion-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.criterion-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.criterion-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.55;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.criterion-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.criterion-more {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
:global(body.dark) .criterion-more {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.criterion-empty {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.target-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.target-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger { color: #ff4d4f; }
|
||||||
|
</style>
|
||||||
254
frontend/src/pages/xray/RuleFormModal.vue
Normal file
254
frontend/src/pages/xray/RuleFormModal.vue
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
// Routing-rule editor — mirrors xray_rule_modal.html. We keep the
|
||||||
|
// CSV-style fields (domain / ip / sourceIP / user / port / sourcePort /
|
||||||
|
// vlessRoute) as plain strings while the modal is open and split them
|
||||||
|
// back to arrays on submit, just like the legacy ruleModal.getResult.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
// null when adding, the rule object when editing.
|
||||||
|
rule: { type: Object, default: null },
|
||||||
|
// Tag pools sourced from templateSettings.{inbounds,outbounds,routing.balancers}
|
||||||
|
// and the parent's inboundTags / clientReverseTags / dnsTag.
|
||||||
|
inboundTags: { type: Array, default: () => [] },
|
||||||
|
outboundTags: { type: Array, default: () => [] },
|
||||||
|
balancerTags: { type: Array, default: () => [''] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'confirm']);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
domain: '',
|
||||||
|
ip: '',
|
||||||
|
port: '',
|
||||||
|
sourcePort: '',
|
||||||
|
vlessRoute: '',
|
||||||
|
network: '',
|
||||||
|
sourceIP: '',
|
||||||
|
user: '',
|
||||||
|
inboundTag: [],
|
||||||
|
protocol: [],
|
||||||
|
attrs: [], // [[key, value], ...]
|
||||||
|
outboundTag: '',
|
||||||
|
balancerTag: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEdit = ref(false);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
form.domain = '';
|
||||||
|
form.ip = '';
|
||||||
|
form.port = '';
|
||||||
|
form.sourcePort = '';
|
||||||
|
form.vlessRoute = '';
|
||||||
|
form.network = '';
|
||||||
|
form.sourceIP = '';
|
||||||
|
form.user = '';
|
||||||
|
form.inboundTag = [];
|
||||||
|
form.protocol = [];
|
||||||
|
form.attrs = [];
|
||||||
|
form.outboundTag = '';
|
||||||
|
form.balancerTag = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (props.rule) {
|
||||||
|
isEdit.value = true;
|
||||||
|
const r = props.rule;
|
||||||
|
form.domain = Array.isArray(r.domain) ? r.domain.join(',') : (r.domain || '');
|
||||||
|
form.ip = Array.isArray(r.ip) ? r.ip.join(',') : (r.ip || '');
|
||||||
|
form.port = r.port || '';
|
||||||
|
form.sourcePort = r.sourcePort || '';
|
||||||
|
form.vlessRoute = r.vlessRoute || '';
|
||||||
|
form.network = r.network || '';
|
||||||
|
form.sourceIP = Array.isArray(r.sourceIP) ? r.sourceIP.join(',') : (r.sourceIP || '');
|
||||||
|
form.user = Array.isArray(r.user) ? r.user.join(',') : (r.user || '');
|
||||||
|
form.inboundTag = r.inboundTag || [];
|
||||||
|
form.protocol = r.protocol || [];
|
||||||
|
// Attrs in the wire shape are an object — flatten to [[k,v]] pairs.
|
||||||
|
form.attrs = r.attrs ? Object.entries(r.attrs) : [];
|
||||||
|
form.outboundTag = r.outboundTag || '';
|
||||||
|
form.balancerTag = r.balancerTag || '';
|
||||||
|
} else {
|
||||||
|
isEdit.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() { emit('update:open', false); }
|
||||||
|
|
||||||
|
function csv(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResult() {
|
||||||
|
const rule = {
|
||||||
|
type: 'field',
|
||||||
|
domain: csv(form.domain),
|
||||||
|
ip: csv(form.ip),
|
||||||
|
port: form.port,
|
||||||
|
sourcePort: form.sourcePort,
|
||||||
|
vlessRoute: form.vlessRoute,
|
||||||
|
network: form.network,
|
||||||
|
sourceIP: csv(form.sourceIP),
|
||||||
|
user: csv(form.user),
|
||||||
|
inboundTag: form.inboundTag,
|
||||||
|
protocol: form.protocol,
|
||||||
|
attrs: Object.fromEntries(form.attrs.filter(([k]) => k)),
|
||||||
|
outboundTag: form.outboundTag === '' ? undefined : form.outboundTag,
|
||||||
|
balancerTag: form.balancerTag === '' ? undefined : form.balancerTag,
|
||||||
|
};
|
||||||
|
// Strip empty arrays / objects / strings so the final wire JSON
|
||||||
|
// matches what the legacy `getResult` produces.
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(rule)) {
|
||||||
|
if (v == null) continue;
|
||||||
|
if (Array.isArray(v) && v.length === 0) continue;
|
||||||
|
if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
|
||||||
|
if (v === '') continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOk() {
|
||||||
|
emit('confirm', buildResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => (isEdit.value ? 'Edit rule' : 'Add rule'));
|
||||||
|
const okText = computed(() => (isEdit.value ? 'Update' : 'Add rule'));
|
||||||
|
|
||||||
|
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
|
||||||
|
const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="title"
|
||||||
|
:ok-text="okText"
|
||||||
|
cancel-text="Close"
|
||||||
|
:mask-closable="false"
|
||||||
|
width="640px"
|
||||||
|
@ok="onOk"
|
||||||
|
@cancel="close"
|
||||||
|
>
|
||||||
|
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">
|
||||||
|
Source IPs <QuestionCircleOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.sourceIP" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">
|
||||||
|
Source port <QuestionCircleOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.sourcePort" placeholder="53,443,1000-2000" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">
|
||||||
|
VLESS route <QuestionCircleOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.vlessRoute" placeholder="53,443,1000-2000" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Network">
|
||||||
|
<a-select v-model:value="form.network">
|
||||||
|
<a-select-option v-for="n in NETWORKS" :key="n" :value="n">{{ n || '(any)' }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Protocol">
|
||||||
|
<a-select v-model:value="form.protocol" mode="multiple">
|
||||||
|
<a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Attributes">
|
||||||
|
<a-button size="small" @click="form.attrs.push(['', ''])">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :wrapper-col="{ span: 24 }">
|
||||||
|
<a-input-group v-for="(attr, idx) in form.attrs" :key="idx" compact class="mb-8">
|
||||||
|
<a-input :style="{ width: '45%' }" v-model:value="attr[0]" placeholder="Name">
|
||||||
|
<template #addonBefore>{{ idx + 1 }}</template>
|
||||||
|
</a-input>
|
||||||
|
<a-input :style="{ width: '45%' }" v-model:value="attr[1]" placeholder="Value" />
|
||||||
|
<a-button @click="form.attrs.splice(idx, 1)">
|
||||||
|
<template #icon><MinusOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">IP <QuestionCircleOutlined /></a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">Domain <QuestionCircleOutlined /></a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.domain" placeholder="google.com, geosite:cn" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">User <QuestionCircleOutlined /></a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.user" placeholder="email address" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Comma-separated list">Port <QuestionCircleOutlined /></a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.port" placeholder="53,443,1000-2000" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Inbound tags">
|
||||||
|
<a-select v-model:value="form.inboundTag" mode="multiple">
|
||||||
|
<a-select-option v-for="t in inboundTags" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Outbound tag">
|
||||||
|
<a-select v-model:value="form.outboundTag">
|
||||||
|
<a-select-option v-for="t in outboundTags" :key="t || '__empty'" :value="t">{{ t || '(none)' }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Routes traffic through one of the configured load balancers">
|
||||||
|
Balancer tag <QuestionCircleOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-select v-model:value="form.balancerTag">
|
||||||
|
<a-select-option v-for="t in balancerTags" :key="t || '__empty'" :value="t">{{ t || '(none)' }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-8 { margin-bottom: 8px; }
|
||||||
|
</style>
|
||||||
|
|
@ -16,6 +16,7 @@ import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import BasicsTab from './BasicsTab.vue';
|
import BasicsTab from './BasicsTab.vue';
|
||||||
|
import RoutingTab from './RoutingTab.vue';
|
||||||
import { useXraySetting } from './useXraySetting.js';
|
import { useXraySetting } from './useXraySetting.js';
|
||||||
|
|
||||||
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
|
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
|
||||||
|
|
@ -36,6 +37,8 @@ const {
|
||||||
xraySetting,
|
xraySetting,
|
||||||
templateSettings,
|
templateSettings,
|
||||||
outboundTestUrl,
|
outboundTestUrl,
|
||||||
|
inboundTags,
|
||||||
|
clientReverseTags,
|
||||||
restartResult,
|
restartResult,
|
||||||
saveAll,
|
saveAll,
|
||||||
restartXray,
|
restartXray,
|
||||||
|
|
@ -140,7 +143,12 @@ function confirmRestart() {
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SwapOutlined /> <span>Routing</span>
|
<SwapOutlined /> <span>Routing</span>
|
||||||
</template>
|
</template>
|
||||||
<a-empty description="Routing rules — coming in 6-iii." />
|
<RoutingTab
|
||||||
|
:template-settings="templateSettings"
|
||||||
|
:inbound-tags="inboundTags"
|
||||||
|
:client-reverse-tags="clientReverseTags"
|
||||||
|
:is-mobile="isMobile"
|
||||||
|
/>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue