OutboundFormModal.vue 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { message } from 'ant-design-vue';
  5. import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
  6. import { Wireguard } from '@/utils';
  7. import {
  8. Outbound,
  9. Protocols,
  10. SSMethods,
  11. TLS_FLOW_CONTROL,
  12. UTLS_FINGERPRINT,
  13. ALPN_OPTION,
  14. SNIFFING_OPTION,
  15. USERS_SECURITY,
  16. OutboundDomainStrategies,
  17. WireguardDomainStrategy,
  18. Address_Port_Strategy,
  19. MODE_OPTION,
  20. DNSRuleActions,
  21. } from '@/models/outbound.js';
  22. import FinalMaskForm from '@/components/FinalMaskForm.vue';
  23. const { t } = useI18n();
  24. // Structured outbound add/edit modal — mirrors the legacy
  25. // web/html/form/outbound.html. Covers every protocol + transport
  26. // combination the legacy panel exposes; the JSON tab still lets
  27. // power-users hand-edit fields the structured form doesn't surface
  28. // (reverse-sniffing, exotic outbound DNS rules, etc.).
  29. const props = defineProps({
  30. open: { type: Boolean, default: false },
  31. outbound: { type: Object, default: null },
  32. existingTags: { type: Array, default: () => [] },
  33. });
  34. const emit = defineEmits(['update:open', 'confirm']);
  35. const PROTOCOL_OPTIONS = Object.values(Protocols);
  36. const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
  37. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  38. const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT);
  39. const ALPN_OPTIONS = Object.values(ALPN_OPTION);
  40. const NETWORKS = ['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp'];
  41. const NETWORK_LABELS = {
  42. tcp: 'TCP (RAW)',
  43. kcp: 'mKCP',
  44. ws: 'WebSocket',
  45. grpc: 'gRPC',
  46. httpupgrade: 'HTTPUpgrade',
  47. xhttp: 'XHTTP',
  48. };
  49. // Reactive draft — Outbound instance built from the prop on open.
  50. // Intentionally shadows the prop name; the template reads the draft.
  51. // eslint-disable-next-line vue/no-dupe-keys
  52. const outbound = ref(null);
  53. const isEdit = ref(false);
  54. const activeKey = ref('1');
  55. const linkInput = ref('');
  56. // Advanced JSON editor — kept in sync with the parsed Outbound on tab
  57. // switch so users can copy/paste a full JSON config when the structured
  58. // form doesn't reach a field.
  59. const advancedJson = ref('');
  60. watch(() => props.open, (next) => {
  61. if (!next) return;
  62. if (props.outbound) {
  63. isEdit.value = true;
  64. outbound.value = Outbound.fromJson(props.outbound);
  65. } else {
  66. isEdit.value = false;
  67. outbound.value = new Outbound();
  68. }
  69. activeKey.value = '1';
  70. linkInput.value = '';
  71. primeAdvancedJson();
  72. });
  73. watch(activeKey, (key) => {
  74. if (key === '2') primeAdvancedJson();
  75. });
  76. function primeAdvancedJson() {
  77. if (!outbound.value) { advancedJson.value = ''; return; }
  78. try {
  79. advancedJson.value = JSON.stringify(outbound.value.toJson(), null, 2);
  80. } catch (_e) {
  81. advancedJson.value = '';
  82. }
  83. }
  84. function close() { emit('update:open', false); }
  85. function onProtocolChange(next) {
  86. if (!outbound.value) return;
  87. outbound.value.protocol = next;
  88. }
  89. function streamNetworkChange(next) {
  90. if (!outbound.value?.stream) return;
  91. outbound.value.stream.network = next;
  92. if (!outbound.value.canEnableTls()) outbound.value.stream.security = 'none';
  93. }
  94. const duplicateTag = computed(() => {
  95. if (!outbound.value?.tag) return false;
  96. const myTag = outbound.value.tag.trim();
  97. if (!myTag) return false;
  98. if (isEdit.value && props.outbound?.tag === myTag) return false;
  99. return (props.existingTags || []).includes(myTag);
  100. });
  101. const tagEmpty = computed(() => !outbound.value?.tag?.trim());
  102. const tagValidateStatus = computed(() => {
  103. if (tagEmpty.value) return 'error';
  104. if (duplicateTag.value) return 'warning';
  105. return 'success';
  106. });
  107. const tagHelp = computed(() => {
  108. if (tagEmpty.value) return 'Tag is required';
  109. if (duplicateTag.value) return 'Tag already used by another outbound';
  110. return '';
  111. });
  112. // ============== Submit ==============
  113. function onOk() {
  114. if (!outbound.value) return;
  115. if (!outbound.value.tag?.trim()) {
  116. message.error(t('somethingWentWrong'));
  117. return;
  118. }
  119. if (duplicateTag.value) {
  120. message.error(t('somethingWentWrong'));
  121. return;
  122. }
  123. // If user spent time in the JSON tab, prefer that body — round-trip
  124. // it through Outbound.fromJson so the wire shape stays consistent.
  125. if (activeKey.value === '2' && advancedJson.value.trim()) {
  126. try {
  127. const parsed = JSON.parse(advancedJson.value);
  128. const built = Outbound.fromJson(parsed);
  129. emit('confirm', built.toJson());
  130. return;
  131. } catch (e) {
  132. message.error(`JSON: ${e.message}`);
  133. return;
  134. }
  135. }
  136. emit('confirm', outbound.value.toJson());
  137. }
  138. // ============== Link → outbound ==============
  139. // Mirrors the legacy convertLink: dispatches into Outbound.fromLink,
  140. // which handles vmess:// (base64 JSON), vless://, trojan://, ss://
  141. // (param-link form), and hysteria(2)://. Anything else returns null
  142. // from the model and we surface "Wrong Link!" the same as legacy.
  143. function convertLink() {
  144. const link = linkInput.value.trim();
  145. if (!link) return;
  146. try {
  147. const next = Outbound.fromLink(link);
  148. if (!next) {
  149. message.error('Wrong Link!');
  150. return;
  151. }
  152. outbound.value = next;
  153. linkInput.value = '';
  154. message.success('Link imported successfully...');
  155. activeKey.value = '1';
  156. } catch (e) {
  157. message.error(`Link parse: ${e.message}`);
  158. }
  159. }
  160. const title = computed(() =>
  161. isEdit.value
  162. ? `${t('edit')} ${t('pages.xray.Outbounds')}`
  163. : `+ ${t('pages.xray.Outbounds')}`,
  164. );
  165. const okText = computed(() =>
  166. isEdit.value ? t('pages.client.submitEdit') : t('create'),
  167. );
  168. // Helper getters / shortcuts used by the template.
  169. const proto = computed(() => outbound.value?.protocol);
  170. const isVMess = computed(() => proto.value === Protocols.VMess);
  171. const isVLESS = computed(() => proto.value === Protocols.VLESS);
  172. const isVMessOrVLess = computed(() => isVMess.value || isVLESS.value);
  173. const isTrojan = computed(() => proto.value === Protocols.Trojan);
  174. const isShadowsocks = computed(() => proto.value === Protocols.Shadowsocks);
  175. const isFreedom = computed(() => proto.value === Protocols.Freedom);
  176. const isBlackhole = computed(() => proto.value === Protocols.Blackhole);
  177. const isDNS = computed(() => proto.value === Protocols.DNS);
  178. const isWireguard = computed(() => proto.value === Protocols.Wireguard);
  179. const isHysteria = computed(() => proto.value === Protocols.Hysteria);
  180. const isLoopback = computed(() => proto.value === Protocols.Loopback);
  181. function regenerateWgKeys() {
  182. if (!outbound.value?.settings) return;
  183. const pair = Wireguard.generateKeypair();
  184. outbound.value.settings.secretKey = pair.privateKey;
  185. outbound.value.settings.pubKey = pair.publicKey;
  186. }
  187. </script>
  188. <template>
  189. <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="780px"
  190. @ok="onOk" @cancel="close">
  191. <a-tabs v-if="outbound" v-model:active-key="activeKey">
  192. <!-- ============================== FORM ============================== -->
  193. <a-tab-pane key="1" :tab="t('pages.xray.basicTemplate')">
  194. <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
  195. <!-- Protocol -->
  196. <a-form-item :label="t('protocol')">
  197. <a-select :value="proto" @change="onProtocolChange">
  198. <a-select-option v-for="p in PROTOCOL_OPTIONS" :key="p" :value="p">{{ p }}</a-select-option>
  199. </a-select>
  200. </a-form-item>
  201. <!-- Tag -->
  202. <a-form-item label="Tag" :validate-status="tagValidateStatus" :help="tagHelp" has-feedback>
  203. <a-input v-model:value="outbound.tag" placeholder="unique-tag" />
  204. </a-form-item>
  205. <!-- Send through -->
  206. <a-form-item label="Send through">
  207. <a-input v-model:value="outbound.sendThrough" placeholder="local IP" />
  208. </a-form-item>
  209. <!-- ============== Freedom ============== -->
  210. <template v-if="isFreedom">
  211. <a-form-item label="Strategy">
  212. <a-select v-model:value="outbound.settings.domainStrategy">
  213. <a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
  214. </a-select>
  215. </a-form-item>
  216. <a-form-item label="Redirect">
  217. <a-input v-model:value="outbound.settings.redirect" />
  218. </a-form-item>
  219. <a-form-item label="Fragment">
  220. <a-switch :checked="!!outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0"
  221. @change="(checked) => outbound.settings.fragment = checked ? { packets: 'tlshello', length: '100-200', interval: '10-20', maxSplit: '300-400' } : {}" />
  222. </a-form-item>
  223. <template v-if="outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0">
  224. <a-form-item label="Packets">
  225. <a-select v-model:value="outbound.settings.fragment.packets">
  226. <a-select-option v-for="p in ['1-3', 'tlshello']" :key="p" :value="p">{{ p }}</a-select-option>
  227. </a-select>
  228. </a-form-item>
  229. <a-form-item label="Length">
  230. <a-input v-model:value="outbound.settings.fragment.length" placeholder="100-200" />
  231. </a-form-item>
  232. <a-form-item label="Interval">
  233. <a-input v-model:value="outbound.settings.fragment.interval" placeholder="10-20" />
  234. </a-form-item>
  235. <a-form-item label="Max Split">
  236. <a-input v-model:value="outbound.settings.fragment.maxSplit" placeholder="300-400" />
  237. </a-form-item>
  238. </template>
  239. <a-form-item label="Noises">
  240. <a-switch :checked="(outbound.settings.noises || []).length > 0"
  241. @change="(checked) => outbound.settings.noises = checked ? [new Outbound.FreedomSettings.Noise()] : []" />
  242. <a-button v-if="outbound.settings.noises && outbound.settings.noises.length > 0" size="small"
  243. type="primary" class="ml-8"
  244. @click="outbound.settings.noises.push(new Outbound.FreedomSettings.Noise())">
  245. <template #icon>
  246. <PlusOutlined />
  247. </template>
  248. </a-button>
  249. </a-form-item>
  250. <template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
  251. <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
  252. <div class="item-heading">
  253. <span>Noise {{ index + 1 }}</span>
  254. <DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
  255. @click="outbound.settings.noises.splice(index, 1)" />
  256. </div>
  257. </a-form-item>
  258. <a-form-item label="Type">
  259. <a-select v-model:value="noise.type">
  260. <a-select-option v-for="x in ['rand', 'base64', 'str', 'hex']" :key="x" :value="x">{{ x
  261. }}</a-select-option>
  262. </a-select>
  263. </a-form-item>
  264. <a-form-item label="Packet">
  265. <a-input v-model:value="noise.packet" />
  266. </a-form-item>
  267. <a-form-item label="Delay (ms)">
  268. <a-input v-model:value="noise.delay" />
  269. </a-form-item>
  270. <a-form-item label="Apply to">
  271. <a-select v-model:value="noise.applyTo">
  272. <a-select-option v-for="x in ['ip', 'ipv4', 'ipv6']" :key="x" :value="x">{{ x }}</a-select-option>
  273. </a-select>
  274. </a-form-item>
  275. </template>
  276. </template>
  277. <!-- ============== Blackhole ============== -->
  278. <template v-if="isBlackhole">
  279. <a-form-item label="Response Type">
  280. <a-select v-model:value="outbound.settings.type">
  281. <a-select-option v-for="x in ['', 'none', 'http']" :key="x" :value="x">{{ x || '(empty)'
  282. }}</a-select-option>
  283. </a-select>
  284. </a-form-item>
  285. </template>
  286. <!-- ============== Loopback ============== -->
  287. <template v-if="isLoopback">
  288. <a-form-item label="Inbound tag">
  289. <a-input v-model:value="outbound.settings.inboundTag"
  290. placeholder="inbound tag using in routing rules" />
  291. </a-form-item>
  292. </template>
  293. <!-- ============== DNS ============== -->
  294. <template v-if="isDNS">
  295. <a-form-item label="Rewrite network">
  296. <a-select v-model:value="outbound.settings.rewriteNetwork" allow-clear placeholder="(unchanged)">
  297. <a-select-option v-for="x in ['udp', 'tcp']" :key="x" :value="x">{{ x }}</a-select-option>
  298. </a-select>
  299. </a-form-item>
  300. <a-form-item label="Rewrite address">
  301. <a-input v-model:value="outbound.settings.rewriteAddress" placeholder="(unchanged) e.g. 1.1.1.1" />
  302. </a-form-item>
  303. <a-form-item label="Rewrite port">
  304. <a-input-number v-model:value="outbound.settings.rewritePort" :min="0" :max="65535"
  305. :style="{ width: '100%' }" placeholder="(unchanged)" />
  306. </a-form-item>
  307. <a-form-item label="User level">
  308. <a-input-number v-model:value="outbound.settings.userLevel" :min="0" :style="{ width: '100%' }" />
  309. </a-form-item>
  310. <a-form-item label="Rules">
  311. <a-button size="small" type="primary" @click="outbound.settings.rules.push(new Outbound.DNSRule())">
  312. <template #icon>
  313. <PlusOutlined />
  314. </template>
  315. </a-button>
  316. </a-form-item>
  317. <template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
  318. <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
  319. <div class="item-heading">
  320. <span>Rule {{ index + 1 }}</span>
  321. <DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
  322. </div>
  323. </a-form-item>
  324. <a-form-item label="Action">
  325. <a-select v-model:value="rule.action">
  326. <a-select-option v-for="a in DNSRuleActions" :key="a" :value="a">{{ a }}</a-select-option>
  327. </a-select>
  328. </a-form-item>
  329. <a-form-item label="QType">
  330. <a-input v-model:value="rule.qtype" placeholder="1,3,23-24" />
  331. </a-form-item>
  332. <a-form-item :label="t('domainName')">
  333. <a-input v-model:value="rule.domain" placeholder="domain:example.com" />
  334. </a-form-item>
  335. </template>
  336. </template>
  337. <!-- ============== WireGuard ============== -->
  338. <template v-if="isWireguard">
  339. <a-form-item :label="t('pages.inbounds.address')">
  340. <a-input v-model:value="outbound.settings.address" />
  341. </a-form-item>
  342. <a-form-item>
  343. <template #label>
  344. {{ t('pages.inbounds.privatekey') }}
  345. <SyncOutlined class="random-icon" @click="regenerateWgKeys" />
  346. </template>
  347. <a-input v-model:value="outbound.settings.secretKey" />
  348. </a-form-item>
  349. <a-form-item :label="t('pages.inbounds.publicKey')">
  350. <a-input :value="outbound.settings.pubKey" disabled />
  351. </a-form-item>
  352. <a-form-item label="Domain strategy">
  353. <a-select v-model:value="outbound.settings.domainStrategy">
  354. <a-select-option v-for="x in ['', ...WireguardDomainStrategy]" :key="x || '__'" :value="x">
  355. {{ x || `(${t('none')})` }}
  356. </a-select-option>
  357. </a-select>
  358. </a-form-item>
  359. <a-form-item label="MTU">
  360. <a-input-number v-model:value="outbound.settings.mtu" :min="0" />
  361. </a-form-item>
  362. <a-form-item label="Workers">
  363. <a-input-number v-model:value="outbound.settings.workers" :min="0" />
  364. </a-form-item>
  365. <a-form-item label="No-kernel TUN">
  366. <a-switch v-model:checked="outbound.settings.noKernelTun" />
  367. </a-form-item>
  368. <a-form-item label="Reserved">
  369. <a-input v-model:value="outbound.settings.reserved" />
  370. </a-form-item>
  371. <a-form-item label="Peers">
  372. <a-button size="small" type="primary"
  373. @click="outbound.settings.peers.push(new Outbound.WireguardSettings.Peer())">
  374. <template #icon>
  375. <PlusOutlined />
  376. </template>
  377. </a-button>
  378. </a-form-item>
  379. <template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
  380. <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
  381. <div class="item-heading">
  382. <span>Peer {{ index + 1 }}</span>
  383. <DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
  384. @click="outbound.settings.peers.splice(index, 1)" />
  385. </div>
  386. </a-form-item>
  387. <a-form-item label="Endpoint">
  388. <a-input v-model:value="peer.endpoint" />
  389. </a-form-item>
  390. <a-form-item :label="t('pages.inbounds.publicKey')">
  391. <a-input v-model:value="peer.publicKey" />
  392. </a-form-item>
  393. <a-form-item label="PSK">
  394. <a-input v-model:value="peer.psk" />
  395. </a-form-item>
  396. <a-form-item label="Allowed IPs">
  397. <template v-for="(_, idx) in peer.allowedIPs" :key="idx">
  398. <a-input v-model:value="peer.allowedIPs[idx]" :style="{ marginBottom: '4px' }">
  399. <template v-if="peer.allowedIPs.length > 1" #addonAfter>
  400. <MinusOutlined @click="peer.allowedIPs.splice(idx, 1)" />
  401. </template>
  402. </a-input>
  403. </template>
  404. <a-button size="small" @click="peer.allowedIPs.push('')">
  405. <template #icon>
  406. <PlusOutlined />
  407. </template>
  408. </a-button>
  409. </a-form-item>
  410. <a-form-item label="Keep alive">
  411. <a-input-number v-model:value="peer.keepAlive" :min="0" />
  412. </a-form-item>
  413. </template>
  414. </template>
  415. <!-- ============== Address + Port (most protocols) ============== -->
  416. <template v-if="outbound.hasAddressPort()">
  417. <a-form-item :label="t('pages.inbounds.address')">
  418. <a-input v-model:value="outbound.settings.address" />
  419. </a-form-item>
  420. <a-form-item :label="t('pages.inbounds.port')">
  421. <a-input-number v-model:value="outbound.settings.port" :min="1" :max="65535" />
  422. </a-form-item>
  423. </template>
  424. <!-- ============== VMess / VLess user ============== -->
  425. <template v-if="isVMessOrVLess">
  426. <a-form-item label="ID">
  427. <a-input v-model:value="outbound.settings.id" />
  428. </a-form-item>
  429. <a-form-item v-if="isVMess" :label="t('security')">
  430. <a-select v-model:value="outbound.settings.security">
  431. <a-select-option v-for="s in SECURITY_OPTIONS" :key="s" :value="s">{{ s }}</a-select-option>
  432. </a-select>
  433. </a-form-item>
  434. <a-form-item v-if="isVLESS" :label="t('encryption')">
  435. <a-input v-model:value="outbound.settings.encryption" />
  436. </a-form-item>
  437. <a-form-item v-if="isVLESS" label="Reverse tag">
  438. <a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
  439. </a-form-item>
  440. <!-- Reverse-Sniffing — surfaced only when a reverse tag is set,
  441. mirroring the legacy form. Defaults populated by the model
  442. so the toggle/checkboxes always have a backing field. -->
  443. <template v-if="isVLESS && outbound.settings.reverseTag">
  444. <a-form-item label="Reverse Sniffing">
  445. <a-switch v-model:checked="outbound.settings.reverseSniffing.enabled" />
  446. </a-form-item>
  447. <template v-if="outbound.settings.reverseSniffing.enabled">
  448. <!-- Align the checkbox row with the input fields above —
  449. same span as wrapper-col (14), offset by label-col (8)
  450. so the row starts where Reverse Tag's input starts. -->
  451. <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }">
  452. <a-checkbox-group v-model:value="outbound.settings.reverseSniffing.destOverride"
  453. class="sniffing-options">
  454. <a-checkbox v-for="(value, label) in SNIFFING_OPTION" :key="value" :value="value">{{ label
  455. }}</a-checkbox>
  456. </a-checkbox-group>
  457. </a-form-item>
  458. <a-form-item label="Metadata Only">
  459. <a-switch v-model:checked="outbound.settings.reverseSniffing.metadataOnly" />
  460. </a-form-item>
  461. <a-form-item label="Route Only">
  462. <a-switch v-model:checked="outbound.settings.reverseSniffing.routeOnly" />
  463. </a-form-item>
  464. <a-form-item label="IPs Excluded">
  465. <a-select v-model:value="outbound.settings.reverseSniffing.ipsExcluded" mode="tags"
  466. :token-separators="[',']" placeholder="IP/CIDR/geoip:*/ext:*" :style="{ width: '100%' }" />
  467. </a-form-item>
  468. <a-form-item label="Domains Excluded">
  469. <a-select v-model:value="outbound.settings.reverseSniffing.domainsExcluded" mode="tags"
  470. :token-separators="[',']" placeholder="domain:*/ext:*" :style="{ width: '100%' }" />
  471. </a-form-item>
  472. </template>
  473. </template>
  474. <a-form-item v-if="outbound.canEnableTlsFlow()" label="Flow">
  475. <a-select v-model:value="outbound.settings.flow">
  476. <a-select-option value="">{{ t('none') }}</a-select-option>
  477. <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
  478. </a-select>
  479. </a-form-item>
  480. </template>
  481. <!-- ============== Trojan / Shadowsocks ============== -->
  482. <template v-if="isTrojan || isShadowsocks">
  483. <a-form-item :label="t('password')">
  484. <a-input v-model:value="outbound.settings.password" />
  485. </a-form-item>
  486. </template>
  487. <template v-if="isShadowsocks">
  488. <a-form-item :label="t('encryption')">
  489. <a-select v-model:value="outbound.settings.method">
  490. <a-select-option v-for="(m, k) in SSMethods" :key="m" :value="m">{{ k }}</a-select-option>
  491. </a-select>
  492. </a-form-item>
  493. <a-form-item label="UDP over TCP">
  494. <a-switch v-model:checked="outbound.settings.uot" />
  495. </a-form-item>
  496. <a-form-item label="UoT version">
  497. <a-input-number v-model:value="outbound.settings.UoTVersion" :min="1" :max="2" />
  498. </a-form-item>
  499. </template>
  500. <!-- ============== SOCKS / HTTP ============== -->
  501. <template v-if="outbound.hasUsername()">
  502. <a-form-item :label="t('username')">
  503. <a-input v-model:value="outbound.settings.user" />
  504. </a-form-item>
  505. <a-form-item :label="t('password')">
  506. <a-input v-model:value="outbound.settings.pass" />
  507. </a-form-item>
  508. </template>
  509. <!-- ============== Hysteria ============== -->
  510. <template v-if="isHysteria">
  511. <a-form-item label="Version">
  512. <a-input-number :value="outbound.settings.version || 2" :min="2" :max="2" disabled />
  513. </a-form-item>
  514. </template>
  515. <!-- ============== Stream settings ============== -->
  516. <template v-if="outbound.canEnableStream()">
  517. <a-form-item :label="t('transmission')">
  518. <a-select :value="outbound.stream.network" @change="streamNetworkChange">
  519. <a-select-option v-for="net in (isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS)" :key="net"
  520. :value="net">
  521. {{ NETWORK_LABELS[net] || net }}
  522. </a-select-option>
  523. </a-select>
  524. </a-form-item>
  525. <!-- TCP -->
  526. <template v-if="outbound.stream.network === 'tcp'">
  527. <a-form-item :label="`HTTP ${t('camouflage')}`">
  528. <a-switch :checked="outbound.stream.tcp.type === 'http'"
  529. @change="(checked) => outbound.stream.tcp.type = checked ? 'http' : 'none'" />
  530. </a-form-item>
  531. <template v-if="outbound.stream.tcp.type === 'http'">
  532. <a-form-item :label="t('host')">
  533. <a-input v-model:value="outbound.stream.tcp.host" />
  534. </a-form-item>
  535. <a-form-item :label="t('path')">
  536. <a-input v-model:value="outbound.stream.tcp.path" />
  537. </a-form-item>
  538. </template>
  539. </template>
  540. <!-- KCP -->
  541. <template v-if="outbound.stream.network === 'kcp'">
  542. <a-form-item label="MTU">
  543. <a-input-number v-model:value="outbound.stream.kcp.mtu" :min="0" />
  544. </a-form-item>
  545. <a-form-item label="TTI (ms)">
  546. <a-input-number v-model:value="outbound.stream.kcp.tti" :min="0" />
  547. </a-form-item>
  548. <a-form-item label="Uplink (MB/s)">
  549. <a-input-number v-model:value="outbound.stream.kcp.upCap" :min="0" />
  550. </a-form-item>
  551. <a-form-item label="Downlink (MB/s)">
  552. <a-input-number v-model:value="outbound.stream.kcp.downCap" :min="0" />
  553. </a-form-item>
  554. <a-form-item label="CWND multiplier">
  555. <a-input-number v-model:value="outbound.stream.kcp.cwndMultiplier" :min="1" />
  556. </a-form-item>
  557. <a-form-item label="Max sending window">
  558. <a-input-number v-model:value="outbound.stream.kcp.maxSendingWindow" :min="0" />
  559. </a-form-item>
  560. </template>
  561. <!-- WebSocket -->
  562. <template v-if="outbound.stream.network === 'ws'">
  563. <a-form-item :label="t('host')">
  564. <a-input v-model:value="outbound.stream.ws.host" />
  565. </a-form-item>
  566. <a-form-item :label="t('path')">
  567. <a-input v-model:value="outbound.stream.ws.path" />
  568. </a-form-item>
  569. <a-form-item label="Heartbeat (s)">
  570. <a-input-number v-model:value="outbound.stream.ws.heartbeatPeriod" :min="0" />
  571. </a-form-item>
  572. </template>
  573. <!-- gRPC -->
  574. <template v-if="outbound.stream.network === 'grpc'">
  575. <a-form-item label="Service name">
  576. <a-input v-model:value="outbound.stream.grpc.serviceName" />
  577. </a-form-item>
  578. <a-form-item label="Authority">
  579. <a-input v-model:value="outbound.stream.grpc.authority" />
  580. </a-form-item>
  581. <a-form-item label="Multi mode">
  582. <a-switch v-model:checked="outbound.stream.grpc.multiMode" />
  583. </a-form-item>
  584. </template>
  585. <!-- HTTPUpgrade -->
  586. <template v-if="outbound.stream.network === 'httpupgrade'">
  587. <a-form-item :label="t('host')">
  588. <a-input v-model:value="outbound.stream.httpupgrade.host" />
  589. </a-form-item>
  590. <a-form-item :label="t('path')">
  591. <a-input v-model:value="outbound.stream.httpupgrade.path" />
  592. </a-form-item>
  593. </template>
  594. <!-- XHTTP — full parity with legacy outbound form. The model
  595. already carries every field below; we just surface them. -->
  596. <template v-if="outbound.stream.network === 'xhttp'">
  597. <a-form-item :label="t('host')">
  598. <a-input v-model:value="outbound.stream.xhttp.host" />
  599. </a-form-item>
  600. <a-form-item :label="t('path')">
  601. <a-input v-model:value="outbound.stream.xhttp.path" />
  602. </a-form-item>
  603. <a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
  604. <a-button size="small" @click="outbound.stream.xhttp.addHeader('', '')">
  605. <template #icon>
  606. <PlusOutlined />
  607. </template>
  608. </a-button>
  609. </a-form-item>
  610. <a-form-item :wrapper-col="{ span: 24 }">
  611. <a-input-group v-for="(header, idx) in outbound.stream.xhttp.headers" :key="idx" compact class="mb-8">
  612. <a-input v-model:value="header.name" :style="{ width: '45%' }" placeholder="Name">
  613. <template #addonBefore>{{ idx + 1 }}</template>
  614. </a-input>
  615. <a-input v-model:value="header.value" :style="{ width: '45%' }" placeholder="Value" />
  616. <a-button @click="outbound.stream.xhttp.removeHeader(idx)">
  617. <template #icon>
  618. <MinusOutlined />
  619. </template>
  620. </a-button>
  621. </a-input-group>
  622. </a-form-item>
  623. <a-form-item label="Mode">
  624. <a-select v-model:value="outbound.stream.xhttp.mode">
  625. <a-select-option v-for="m in Object.values(MODE_OPTION)" :key="m" :value="m">{{ m }}</a-select-option>
  626. </a-select>
  627. </a-form-item>
  628. <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Max Upload Size (Byte)">
  629. <a-input v-model:value="outbound.stream.xhttp.scMaxEachPostBytes" />
  630. </a-form-item>
  631. <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Min Upload Interval (Ms)">
  632. <a-input v-model:value="outbound.stream.xhttp.scMinPostsIntervalMs" />
  633. </a-form-item>
  634. <a-form-item label="Padding Bytes">
  635. <a-input v-model:value="outbound.stream.xhttp.xPaddingBytes" />
  636. </a-form-item>
  637. <a-form-item label="Padding Obfs Mode">
  638. <a-switch v-model:checked="outbound.stream.xhttp.xPaddingObfsMode" />
  639. </a-form-item>
  640. <template v-if="outbound.stream.xhttp.xPaddingObfsMode">
  641. <a-form-item label="Padding Key">
  642. <a-input v-model:value="outbound.stream.xhttp.xPaddingKey" placeholder="x_padding" />
  643. </a-form-item>
  644. <a-form-item label="Padding Header">
  645. <a-input v-model:value="outbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding" />
  646. </a-form-item>
  647. <a-form-item label="Padding Placement">
  648. <a-select v-model:value="outbound.stream.xhttp.xPaddingPlacement">
  649. <a-select-option value="">Default (queryInHeader)</a-select-option>
  650. <a-select-option value="queryInHeader">queryInHeader</a-select-option>
  651. <a-select-option value="header">header</a-select-option>
  652. <a-select-option value="cookie">cookie</a-select-option>
  653. <a-select-option value="query">query</a-select-option>
  654. </a-select>
  655. </a-form-item>
  656. <a-form-item label="Padding Method">
  657. <a-select v-model:value="outbound.stream.xhttp.xPaddingMethod">
  658. <a-select-option value="">Default (repeat-x)</a-select-option>
  659. <a-select-option value="repeat-x">repeat-x</a-select-option>
  660. <a-select-option value="tokenish">tokenish</a-select-option>
  661. </a-select>
  662. </a-form-item>
  663. </template>
  664. <a-form-item label="Uplink HTTP Method">
  665. <a-select v-model:value="outbound.stream.xhttp.uplinkHTTPMethod">
  666. <a-select-option value="">Default (POST)</a-select-option>
  667. <a-select-option value="POST">POST</a-select-option>
  668. <a-select-option value="PUT">PUT</a-select-option>
  669. <a-select-option value="GET" :disabled="outbound.stream.xhttp.mode !== 'packet-up'">GET (packet-up
  670. only)</a-select-option>
  671. </a-select>
  672. </a-form-item>
  673. <a-form-item label="Session Placement">
  674. <a-select v-model:value="outbound.stream.xhttp.sessionPlacement">
  675. <a-select-option value="">Default (path)</a-select-option>
  676. <a-select-option value="path">path</a-select-option>
  677. <a-select-option value="header">header</a-select-option>
  678. <a-select-option value="cookie">cookie</a-select-option>
  679. <a-select-option value="query">query</a-select-option>
  680. </a-select>
  681. </a-form-item>
  682. <a-form-item
  683. v-if="outbound.stream.xhttp.sessionPlacement && outbound.stream.xhttp.sessionPlacement !== 'path'"
  684. label="Session Key">
  685. <a-input v-model:value="outbound.stream.xhttp.sessionKey" placeholder="x_session" />
  686. </a-form-item>
  687. <a-form-item label="Sequence Placement">
  688. <a-select v-model:value="outbound.stream.xhttp.seqPlacement">
  689. <a-select-option value="">Default (path)</a-select-option>
  690. <a-select-option value="path">path</a-select-option>
  691. <a-select-option value="header">header</a-select-option>
  692. <a-select-option value="cookie">cookie</a-select-option>
  693. <a-select-option value="query">query</a-select-option>
  694. </a-select>
  695. </a-form-item>
  696. <a-form-item v-if="outbound.stream.xhttp.seqPlacement && outbound.stream.xhttp.seqPlacement !== 'path'"
  697. label="Sequence Key">
  698. <a-input v-model:value="outbound.stream.xhttp.seqKey" placeholder="x_seq" />
  699. </a-form-item>
  700. <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Uplink Data Placement">
  701. <a-select v-model:value="outbound.stream.xhttp.uplinkDataPlacement">
  702. <a-select-option value="">Default (body)</a-select-option>
  703. <a-select-option value="body">body</a-select-option>
  704. <a-select-option value="header">header</a-select-option>
  705. <a-select-option value="cookie">cookie</a-select-option>
  706. <a-select-option value="query">query</a-select-option>
  707. </a-select>
  708. </a-form-item>
  709. <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'
  710. && outbound.stream.xhttp.uplinkDataPlacement
  711. && outbound.stream.xhttp.uplinkDataPlacement !== 'body'" label="Uplink Data Key">
  712. <a-input v-model:value="outbound.stream.xhttp.uplinkDataKey" placeholder="x_data" />
  713. </a-form-item>
  714. <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'
  715. && outbound.stream.xhttp.uplinkDataPlacement
  716. && outbound.stream.xhttp.uplinkDataPlacement !== 'body'" label="Uplink Chunk Size">
  717. <a-input-number v-model:value="outbound.stream.xhttp.uplinkChunkSize" :min="0"
  718. placeholder="0 (unlimited)" />
  719. </a-form-item>
  720. <a-form-item
  721. v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'"
  722. label="No gRPC Header">
  723. <a-switch v-model:checked="outbound.stream.xhttp.noGRPCHeader" />
  724. </a-form-item>
  725. <a-form-item label="XMUX">
  726. <a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
  727. </a-form-item>
  728. <template v-if="outbound.stream.xhttp.enableXmux">
  729. <a-form-item v-if="!outbound.stream.xhttp.xmux.maxConnections" label="Max Concurrency">
  730. <a-input v-model:value="outbound.stream.xhttp.xmux.maxConcurrency" />
  731. </a-form-item>
  732. <a-form-item v-if="!outbound.stream.xhttp.xmux.maxConcurrency" label="Max Connections">
  733. <a-input v-model:value="outbound.stream.xhttp.xmux.maxConnections" />
  734. </a-form-item>
  735. <a-form-item label="Max Reuse Times">
  736. <a-input v-model:value="outbound.stream.xhttp.xmux.cMaxReuseTimes" />
  737. </a-form-item>
  738. <a-form-item label="Max Request Times">
  739. <a-input v-model:value="outbound.stream.xhttp.xmux.hMaxRequestTimes" />
  740. </a-form-item>
  741. <a-form-item label="Max Reusable Secs">
  742. <a-input v-model:value="outbound.stream.xhttp.xmux.hMaxReusableSecs" />
  743. </a-form-item>
  744. <a-form-item label="Keep Alive Period">
  745. <a-input-number v-model:value="outbound.stream.xhttp.xmux.hKeepAlivePeriod" :min="0" />
  746. </a-form-item>
  747. </template>
  748. </template>
  749. <!-- Hysteria transport -->
  750. <template v-if="outbound.stream.network === 'hysteria'">
  751. <a-form-item label="Auth password">
  752. <a-input v-model:value="outbound.stream.hysteria.auth" />
  753. </a-form-item>
  754. <a-form-item label="Congestion">
  755. <a-select v-model:value="outbound.stream.hysteria.congestion">
  756. <a-select-option value="">BBR (auto)</a-select-option>
  757. <a-select-option value="brutal">Brutal</a-select-option>
  758. </a-select>
  759. </a-form-item>
  760. <a-form-item label="Upload">
  761. <a-input v-model:value="outbound.stream.hysteria.up" placeholder="100 mbps" />
  762. </a-form-item>
  763. <a-form-item label="Download">
  764. <a-input v-model:value="outbound.stream.hysteria.down" placeholder="100 mbps" />
  765. </a-form-item>
  766. <a-form-item label="UDP hop port">
  767. <a-input v-model:value="outbound.stream.hysteria.udphopPort" placeholder="1145-1919" />
  768. </a-form-item>
  769. <a-form-item label="Max idle (s)">
  770. <a-input-number v-model:value="outbound.stream.hysteria.maxIdleTimeout" :min="4" :max="120" />
  771. </a-form-item>
  772. <a-form-item label="Keep alive (s)">
  773. <a-input-number v-model:value="outbound.stream.hysteria.keepAlivePeriod" :min="2" :max="60" />
  774. </a-form-item>
  775. <a-form-item label="Disable Path MTU">
  776. <a-switch v-model:checked="outbound.stream.hysteria.disablePathMTUDiscovery" />
  777. </a-form-item>
  778. </template>
  779. </template>
  780. <!-- ============== TLS / Reality ============== -->
  781. <template v-if="outbound.canEnableTls()">
  782. <a-form-item :label="t('security')">
  783. <a-radio-group v-model:value="outbound.stream.security" button-style="solid">
  784. <a-radio-button value="none">{{ t('none') }}</a-radio-button>
  785. <a-radio-button value="tls">TLS</a-radio-button>
  786. <a-radio-button v-if="outbound.canEnableReality()" value="reality">Reality</a-radio-button>
  787. </a-radio-group>
  788. </a-form-item>
  789. <template v-if="outbound.stream.isTls">
  790. <a-form-item label="SNI">
  791. <a-input v-model:value="outbound.stream.tls.serverName" placeholder="server name" />
  792. </a-form-item>
  793. <a-form-item label="uTLS">
  794. <a-select v-model:value="outbound.stream.tls.fingerprint">
  795. <a-select-option value="">{{ t('none') }}</a-select-option>
  796. <a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
  797. </a-select>
  798. </a-form-item>
  799. <a-form-item label="ALPN">
  800. <a-select v-model:value="outbound.stream.tls.alpn" mode="multiple">
  801. <a-select-option v-for="alpn in ALPN_OPTIONS" :key="alpn" :value="alpn">{{ alpn }}</a-select-option>
  802. </a-select>
  803. </a-form-item>
  804. <a-form-item label="ECH">
  805. <a-input v-model:value="outbound.stream.tls.echConfigList" />
  806. </a-form-item>
  807. <a-form-item label="Verify peer name">
  808. <a-input v-model:value="outbound.stream.tls.verifyPeerCertByName" placeholder="cloudflare-dns.com" />
  809. </a-form-item>
  810. <a-form-item label="Pinned SHA256">
  811. <a-input v-model:value="outbound.stream.tls.pinnedPeerCertSha256" placeholder="base64 SHA256" />
  812. </a-form-item>
  813. </template>
  814. <template v-if="outbound.stream.isReality">
  815. <a-form-item label="SNI">
  816. <a-input v-model:value="outbound.stream.reality.serverName" />
  817. </a-form-item>
  818. <a-form-item label="uTLS">
  819. <a-select v-model:value="outbound.stream.reality.fingerprint">
  820. <a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
  821. </a-select>
  822. </a-form-item>
  823. <a-form-item label="Short ID">
  824. <a-input v-model:value="outbound.stream.reality.shortId" />
  825. </a-form-item>
  826. <a-form-item label="SpiderX">
  827. <a-input v-model:value="outbound.stream.reality.spiderX" />
  828. </a-form-item>
  829. <a-form-item :label="t('pages.inbounds.publicKey')">
  830. <a-textarea v-model:value="outbound.stream.reality.publicKey" :auto-size="{ minRows: 2 }" />
  831. </a-form-item>
  832. <a-form-item label="mldsa65 verify">
  833. <a-textarea v-model:value="outbound.stream.reality.mldsa65Verify" :auto-size="{ minRows: 2 }" />
  834. </a-form-item>
  835. </template>
  836. </template>
  837. <!-- ============== sockopt ============== -->
  838. <template v-if="outbound.stream">
  839. <a-form-item label="Sockopts">
  840. <a-switch v-model:checked="outbound.stream.sockoptSwitch" />
  841. </a-form-item>
  842. <template v-if="outbound.stream.sockoptSwitch">
  843. <a-form-item label="Dialer proxy">
  844. <a-input v-model:value="outbound.stream.sockopt.dialerProxy" />
  845. </a-form-item>
  846. <a-form-item label="Address+Port strategy">
  847. <a-select v-model:value="outbound.stream.sockopt.addressPortStrategy">
  848. <a-select-option v-for="key in Object.values(Address_Port_Strategy)" :key="key" :value="key">
  849. {{ key }}
  850. </a-select-option>
  851. </a-select>
  852. </a-form-item>
  853. <a-form-item label="Keep alive interval">
  854. <a-input-number v-model:value="outbound.stream.sockopt.tcpKeepAliveInterval" :min="0" />
  855. </a-form-item>
  856. <a-form-item label="TCP Fast Open">
  857. <a-switch v-model:checked="outbound.stream.sockopt.tcpFastOpen" />
  858. </a-form-item>
  859. <a-form-item label="Multipath TCP">
  860. <a-switch v-model:checked="outbound.stream.sockopt.tcpMptcp" />
  861. </a-form-item>
  862. <a-form-item label="Penetrate">
  863. <a-switch v-model:checked="outbound.stream.sockopt.penetrate" />
  864. </a-form-item>
  865. </template>
  866. </template>
  867. <!-- ============== Mux ============== -->
  868. <template v-if="outbound.canEnableMux()">
  869. <a-form-item :label="t('pages.settings.mux')">
  870. <a-switch v-model:checked="outbound.mux.enabled" />
  871. </a-form-item>
  872. <template v-if="outbound.mux.enabled">
  873. <a-form-item label="Concurrency">
  874. <a-input-number v-model:value="outbound.mux.concurrency" :min="-1" :max="1024" />
  875. </a-form-item>
  876. <a-form-item label="xudp concurrency">
  877. <a-input-number v-model:value="outbound.mux.xudpConcurrency" :min="-1" :max="1024" />
  878. </a-form-item>
  879. <a-form-item label="xudp UDP 443">
  880. <a-select v-model:value="outbound.mux.xudpProxyUDP443">
  881. <a-select-option v-for="x in ['reject', 'allow', 'skip']" :key="x" :value="x">{{ x
  882. }}</a-select-option>
  883. </a-select>
  884. </a-form-item>
  885. </template>
  886. </template>
  887. </a-form>
  888. <!-- ============== FinalMask (TCP/UDP masks + QUIC params) ============== -->
  889. <!-- Gated by canEnableStream() so TCP masks don't leak into
  890. Freedom / Blackhole / DNS / Socks / HTTP / Wireguard outbounds
  891. (they don't have a stream config at all). Matches legacy. -->
  892. <FinalMaskForm v-if="outbound.stream && outbound.canEnableStream()" :stream="outbound.stream"
  893. :protocol="proto" />
  894. </a-tab-pane>
  895. <!-- ============================== JSON ============================== -->
  896. <a-tab-pane key="2" tab="JSON">
  897. <a-space direction="vertical" :size="10" :style="{ width: '100%', marginTop: '10px' }">
  898. <a-input-search v-model:value="linkInput" placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
  899. @search="convertLink">
  900. <template #enterButton>
  901. <a-button>Convert</a-button>
  902. </template>
  903. </a-input-search>
  904. <a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
  905. class="json-editor" />
  906. </a-space>
  907. </a-tab-pane>
  908. </a-tabs>
  909. </a-modal>
  910. </template>
  911. <style scoped>
  912. .random-icon {
  913. cursor: pointer;
  914. color: var(--ant-primary-color, #1890ff);
  915. margin-left: 4px;
  916. }
  917. .danger-icon {
  918. cursor: pointer;
  919. color: #ff4d4f;
  920. margin-left: 8px;
  921. }
  922. .ml-8 {
  923. margin-left: 8px;
  924. }
  925. .mb-8 {
  926. margin-bottom: 8px;
  927. }
  928. .section-heading {
  929. font-weight: 500;
  930. margin: 12px 0 6px;
  931. opacity: 0.85;
  932. }
  933. .item-heading {
  934. display: flex;
  935. align-items: center;
  936. justify-content: space-between;
  937. gap: 8px;
  938. font-weight: 500;
  939. opacity: 0.85;
  940. }
  941. .json-editor {
  942. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  943. font-size: 12px;
  944. }
  945. /* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
  946. * inline-block, but inside a narrow form wrapper they can wrap
  947. * inconsistently. Force a clean horizontal row with even gaps. */
  948. .sniffing-options {
  949. display: flex;
  950. flex-wrap: wrap;
  951. gap: 8px 16px;
  952. }
  953. .sniffing-options :deep(.ant-checkbox-wrapper) {
  954. margin-inline-start: 0;
  955. }
  956. </style>