1
0

RoutingTab.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <script setup>
  2. import { computed, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import {
  5. PlusOutlined,
  6. MoreOutlined,
  7. EditOutlined,
  8. DeleteOutlined,
  9. ExportOutlined,
  10. ClusterOutlined,
  11. ArrowUpOutlined,
  12. ArrowDownOutlined,
  13. } from '@ant-design/icons-vue';
  14. import { Modal } from 'ant-design-vue';
  15. import RuleFormModal from './RuleFormModal.vue';
  16. const { t } = useI18n();
  17. // Routing tab — table over templateSettings.routing.rules with the
  18. // modernised legacy column layout. Each row is rendered as a single
  19. // "lead value + N more" pill per criterion (matches the legacy pill
  20. // layout); full lists surface via tooltip on hover.
  21. //
  22. // Reorder uses up/down buttons in the action menu rather than the
  23. // jQuery-Sortable drag handle the legacy panel used — same effect,
  24. // no extra dep. The mobile column layout drops source/network/
  25. // destination criteria for readability.
  26. const props = defineProps({
  27. templateSettings: { type: Object, default: null },
  28. inboundTags: { type: Array, default: () => [] },
  29. clientReverseTags: { type: Array, default: () => [] },
  30. isMobile: { type: Boolean, default: false },
  31. });
  32. // === Table data — match the legacy routingRuleData shape ============
  33. // Convert array criteria to CSV strings so the pill renderer can
  34. // split + summarise them without needing a separate path per shape.
  35. const rows = computed(() => {
  36. if (!props.templateSettings?.routing?.rules) return [];
  37. return props.templateSettings.routing.rules.map((rule, idx) => {
  38. const r = { key: idx, ...rule };
  39. if (Array.isArray(r.domain)) r.domain = r.domain.join(',');
  40. if (Array.isArray(r.ip)) r.ip = r.ip.join(',');
  41. if (Array.isArray(r.source)) r.source = r.source.join(',');
  42. if (Array.isArray(r.user)) r.user = r.user.join(',');
  43. if (Array.isArray(r.inboundTag)) r.inboundTag = r.inboundTag.join(',');
  44. if (Array.isArray(r.protocol)) r.protocol = r.protocol.join(',');
  45. if (r.attrs && typeof r.attrs === 'object' && !Array.isArray(r.attrs)) {
  46. r.attrs = JSON.stringify(r.attrs, null, 2);
  47. }
  48. return r;
  49. });
  50. });
  51. function csv(value) {
  52. if (!value) return [];
  53. return String(value).split(',').map((s) => s.trim()).filter(Boolean);
  54. }
  55. // === Modal state ====================================================
  56. const ruleModalOpen = ref(false);
  57. const editingRule = ref(null);
  58. const editingIndex = ref(null);
  59. const inboundTagOptions = computed(() => {
  60. const out = new Set();
  61. for (const ib of props.templateSettings?.inbounds || []) {
  62. if (ib.tag) out.add(ib.tag);
  63. }
  64. for (const t of props.inboundTags || []) out.add(t);
  65. for (const ob of props.templateSettings?.outbounds || []) {
  66. const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag;
  67. if (rt) out.add(rt);
  68. }
  69. // dnsTag if DNS is configured.
  70. const dt = props.templateSettings?.dns?.tag;
  71. if (dt) out.add(dt);
  72. return [...out];
  73. });
  74. const outboundTagOptions = computed(() => {
  75. const out = new Set(['']);
  76. for (const ob of props.templateSettings?.outbounds || []) {
  77. if (ob.tag) out.add(ob.tag);
  78. }
  79. for (const t of props.clientReverseTags || []) {
  80. if (t) out.add(t);
  81. }
  82. return [...out];
  83. });
  84. const balancerTagOptions = computed(() => {
  85. const out = [''];
  86. for (const b of props.templateSettings?.routing?.balancers || []) {
  87. if (b.tag) out.push(b.tag);
  88. }
  89. return out;
  90. });
  91. function openAdd() {
  92. editingRule.value = null;
  93. editingIndex.value = null;
  94. ruleModalOpen.value = true;
  95. }
  96. function openEdit(idx) {
  97. editingRule.value = props.templateSettings.routing.rules[idx];
  98. editingIndex.value = idx;
  99. ruleModalOpen.value = true;
  100. }
  101. function onRuleConfirm(rule) {
  102. // Empty submit (e.g. user clears every field) collapses to an
  103. // object with only `type: "field"`. Match legacy: skip the write
  104. // when the result is essentially empty.
  105. if (JSON.stringify(rule).length <= 3) {
  106. ruleModalOpen.value = false;
  107. return;
  108. }
  109. if (editingIndex.value == null) {
  110. props.templateSettings.routing.rules.push(rule);
  111. } else {
  112. props.templateSettings.routing.rules[editingIndex.value] = rule;
  113. }
  114. ruleModalOpen.value = false;
  115. }
  116. function confirmDelete(idx) {
  117. Modal.confirm({
  118. title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
  119. okText: t('delete'),
  120. okType: 'danger',
  121. cancelText: t('cancel'),
  122. onOk: () => { props.templateSettings.routing.rules.splice(idx, 1); },
  123. });
  124. }
  125. function moveUp(idx) {
  126. if (idx <= 0) return;
  127. const rules = props.templateSettings.routing.rules;
  128. [rules[idx - 1], rules[idx]] = [rules[idx], rules[idx - 1]];
  129. }
  130. function moveDown(idx) {
  131. const rules = props.templateSettings.routing.rules;
  132. if (idx >= rules.length - 1) return;
  133. [rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
  134. }
  135. // === Columns =========================================================
  136. // Computed so titles re-render after a locale swap.
  137. const desktopColumns = computed(() => [
  138. { title: '#', align: 'center', width: 70, key: 'action' },
  139. { title: 'Source', align: 'left', width: 180, key: 'source' },
  140. { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
  141. { title: 'Destination', align: 'left', key: 'destination' },
  142. { title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
  143. { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
  144. ]);
  145. const mobileColumns = computed(() => [
  146. { title: '#', align: 'center', width: 70, key: 'action' },
  147. { title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
  148. { title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
  149. ]);
  150. const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
  151. </script>
  152. <template>
  153. <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
  154. <a-button type="primary" @click="openAdd">
  155. <template #icon><PlusOutlined /></template>
  156. {{ t('pages.xray.Routings') }}
  157. </a-button>
  158. <a-table
  159. :columns="columns"
  160. :data-source="rows"
  161. :row-key="(r) => r.key"
  162. :pagination="false"
  163. :scroll="isMobile ? {} : { x: 1000 }"
  164. size="small"
  165. class="routing-table"
  166. >
  167. <template #bodyCell="{ column, record, index }">
  168. <!-- ============== # / actions ============== -->
  169. <template v-if="column.key === 'action'">
  170. <div class="action-cell">
  171. <span class="row-index">{{ index + 1 }}</span>
  172. <a-dropdown :trigger="['click']">
  173. <a-button shape="circle" size="small">
  174. <MoreOutlined />
  175. </a-button>
  176. <template #overlay>
  177. <a-menu>
  178. <a-menu-item @click="openEdit(index)">
  179. <EditOutlined /> {{ t('edit') }}
  180. </a-menu-item>
  181. <a-menu-item :disabled="index === 0" @click="moveUp(index)">
  182. <ArrowUpOutlined />
  183. </a-menu-item>
  184. <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
  185. <ArrowDownOutlined />
  186. </a-menu-item>
  187. <a-menu-item class="danger" @click="confirmDelete(index)">
  188. <DeleteOutlined /> {{ t('delete') }}
  189. </a-menu-item>
  190. </a-menu>
  191. </template>
  192. </a-dropdown>
  193. </div>
  194. </template>
  195. <!-- ============== Source ============== -->
  196. <template v-else-if="column.key === 'source'">
  197. <div class="criterion-flow">
  198. <a-tooltip v-if="record.sourceIP" :title="`Source IP: ${record.sourceIP}`">
  199. <span class="criterion-row">
  200. <span class="criterion-label">IP</span>
  201. <span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
  202. <span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1 }}</span>
  203. </span>
  204. </a-tooltip>
  205. <a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
  206. <span class="criterion-row">
  207. <span class="criterion-label">Port</span>
  208. <span class="criterion-value">{{ csv(record.sourcePort)[0] }}</span>
  209. <span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length - 1 }}</span>
  210. </span>
  211. </a-tooltip>
  212. <a-tooltip v-if="record.vlessRoute" :title="`VLESS route: ${record.vlessRoute}`">
  213. <span class="criterion-row">
  214. <span class="criterion-label">VLESS</span>
  215. <span class="criterion-value">{{ csv(record.vlessRoute)[0] }}</span>
  216. <span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length - 1 }}</span>
  217. </span>
  218. </a-tooltip>
  219. <span v-if="!record.sourceIP && !record.sourcePort && !record.vlessRoute" class="criterion-empty">—</span>
  220. </div>
  221. </template>
  222. <!-- ============== Network ============== -->
  223. <template v-else-if="column.key === 'network'">
  224. <div class="criterion-flow">
  225. <a-tooltip v-if="record.network" :title="`L4: ${record.network}`">
  226. <span class="criterion-row">
  227. <span class="criterion-label">L4</span>
  228. <span class="criterion-value">{{ csv(record.network)[0] }}</span>
  229. <span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1 }}</span>
  230. </span>
  231. </a-tooltip>
  232. <a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
  233. <span class="criterion-row">
  234. <span class="criterion-label">Protocol</span>
  235. <span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
  236. <span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1 }}</span>
  237. </span>
  238. </a-tooltip>
  239. <a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
  240. <span class="criterion-row">
  241. <span class="criterion-label">Attrs</span>
  242. <span class="criterion-value">{{ csv(record.attrs)[0] }}</span>
  243. </span>
  244. </a-tooltip>
  245. <span v-if="!record.network && !record.protocol && !record.attrs" class="criterion-empty">—</span>
  246. </div>
  247. </template>
  248. <!-- ============== Destination ============== -->
  249. <template v-else-if="column.key === 'destination'">
  250. <div class="criterion-flow">
  251. <a-tooltip v-if="record.ip" :title="`Destination IP: ${record.ip}`">
  252. <span class="criterion-row">
  253. <span class="criterion-label">IP</span>
  254. <span class="criterion-value">{{ csv(record.ip)[0] }}</span>
  255. <span v-if="csv(record.ip).length > 1" class="criterion-more">+{{ csv(record.ip).length - 1 }}</span>
  256. </span>
  257. </a-tooltip>
  258. <a-tooltip v-if="record.domain" :title="`Domain: ${record.domain}`">
  259. <span class="criterion-row">
  260. <span class="criterion-label">Domain</span>
  261. <span class="criterion-value">{{ csv(record.domain)[0] }}</span>
  262. <span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1 }}</span>
  263. </span>
  264. </a-tooltip>
  265. <a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
  266. <span class="criterion-row">
  267. <span class="criterion-label">Port</span>
  268. <span class="criterion-value">{{ csv(record.port)[0] }}</span>
  269. <span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1 }}</span>
  270. </span>
  271. </a-tooltip>
  272. <span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
  273. </div>
  274. </template>
  275. <!-- ============== Inbound ============== -->
  276. <template v-else-if="column.key === 'inbound'">
  277. <div class="criterion-flow">
  278. <a-tooltip v-if="record.inboundTag" :title="`Inbound tag: ${record.inboundTag}`">
  279. <span class="criterion-row">
  280. <span class="criterion-label">Tag</span>
  281. <span class="criterion-value">{{ csv(record.inboundTag)[0] }}</span>
  282. <span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length - 1 }}</span>
  283. </span>
  284. </a-tooltip>
  285. <a-tooltip v-if="record.user" :title="`User: ${record.user}`">
  286. <span class="criterion-row">
  287. <span class="criterion-label">User</span>
  288. <span class="criterion-value">{{ csv(record.user)[0] }}</span>
  289. <span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1 }}</span>
  290. </span>
  291. </a-tooltip>
  292. <span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
  293. </div>
  294. </template>
  295. <!-- ============== Outbound / balancer target ============== -->
  296. <template v-else-if="column.key === 'target'">
  297. <div class="target-cell">
  298. <div v-if="record.outboundTag" class="target-row">
  299. <ExportOutlined class="target-icon" />
  300. <a-tag color="green">{{ record.outboundTag }}</a-tag>
  301. </div>
  302. <div v-if="record.balancerTag" class="target-row">
  303. <ClusterOutlined class="target-icon" />
  304. <a-tag color="purple">{{ record.balancerTag }}</a-tag>
  305. </div>
  306. <span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
  307. </div>
  308. </template>
  309. </template>
  310. </a-table>
  311. <RuleFormModal
  312. v-model:open="ruleModalOpen"
  313. :rule="editingRule"
  314. :inbound-tags="inboundTagOptions"
  315. :outbound-tags="outboundTagOptions"
  316. :balancer-tags="balancerTagOptions"
  317. @confirm="onRuleConfirm"
  318. />
  319. </a-space>
  320. </template>
  321. <style scoped>
  322. .action-cell {
  323. display: flex;
  324. align-items: center;
  325. gap: 6px;
  326. }
  327. .row-index {
  328. font-weight: 500;
  329. opacity: 0.7;
  330. min-width: 18px;
  331. text-align: right;
  332. }
  333. .criterion-flow {
  334. display: flex;
  335. flex-direction: column;
  336. gap: 2px;
  337. font-size: 12px;
  338. }
  339. .criterion-row {
  340. display: inline-flex;
  341. align-items: baseline;
  342. gap: 4px;
  343. white-space: nowrap;
  344. }
  345. .criterion-label {
  346. font-size: 10px;
  347. text-transform: uppercase;
  348. opacity: 0.55;
  349. letter-spacing: 0.04em;
  350. }
  351. .criterion-value {
  352. font-weight: 500;
  353. }
  354. .criterion-more {
  355. font-size: 11px;
  356. padding: 0 5px;
  357. border-radius: 8px;
  358. background: rgba(0, 0, 0, 0.06);
  359. }
  360. :global(body.dark) .criterion-more {
  361. background: rgba(255, 255, 255, 0.1);
  362. }
  363. .criterion-empty {
  364. opacity: 0.4;
  365. }
  366. .target-cell {
  367. display: flex;
  368. flex-direction: column;
  369. gap: 2px;
  370. }
  371. .target-row {
  372. display: flex;
  373. align-items: center;
  374. gap: 4px;
  375. }
  376. .target-icon {
  377. font-size: 12px;
  378. opacity: 0.6;
  379. }
  380. .danger { color: #ff4d4f; }
  381. </style>