BasicsTab.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. <script setup>
  2. import { computed } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
  5. import { Modal } from 'ant-design-vue';
  6. import { OutboundDomainStrategies } from '@/models/outbound.js';
  7. import SettingListItem from '@/components/SettingListItem.vue';
  8. const { t } = useI18n();
  9. // Phase 6-ii: structured editor for the most-touched fields of the
  10. // xray template — outbound strategy, routing strategy, log levels,
  11. // stat counters, and the "basic routing" lists (block IPs/domains/
  12. // torrent + direct IPs/domains + IPv4 forced + warp/nord domains).
  13. //
  14. // Mutates the parent's templateSettings reactive directly. The
  15. // useXraySetting composable's deep watch on templateSettings re-
  16. // stringifies into xraySetting so the Advanced JSON tab and the
  17. // dirty-poll see every edit.
  18. const props = defineProps({
  19. templateSettings: { type: Object, default: null },
  20. outboundTestUrl: { type: String, default: '' },
  21. warpExist: { type: Boolean, default: false },
  22. nordExist: { type: Boolean, default: false },
  23. });
  24. const emit = defineEmits(['update:outbound-test-url', 'show-warp', 'show-nord', 'reset-default']);
  25. function confirmResetDefault() {
  26. Modal.confirm({
  27. title: t('pages.settings.resetDefaultConfig'),
  28. okText: t('reset'),
  29. okType: 'danger',
  30. cancelText: t('cancel'),
  31. onOk: () => { emit('reset-default'); },
  32. });
  33. }
  34. // === Static option lists (mirror legacy) =============================
  35. const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
  36. const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
  37. const ACCESS_LOG = ['none', './access.log'];
  38. const ERROR_LOG = ['none', './error.log'];
  39. const MASK_ADDRESS = ['quarter', 'half', 'full'];
  40. const BITTORRENT_PROTOCOLS = ['bittorrent'];
  41. // Country / service lists mirror the legacy panel's settingsData
  42. // (web/html/xray.html on main). Keep additions in sync with that file
  43. // so Vue 3 + legacy stay swappable while the migration finishes.
  44. const IPS_OPTIONS = [
  45. { label: 'Private IPs', value: 'geoip:private' },
  46. { label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
  47. { label: '🇨🇳 China', value: 'geoip:cn' },
  48. { label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
  49. { label: '🇻🇳 Vietnam', value: 'geoip:vn' },
  50. { label: '🇪🇸 Spain', value: 'geoip:es' },
  51. { label: '🇮🇩 Indonesia', value: 'geoip:id' },
  52. { label: '🇺🇦 Ukraine', value: 'geoip:ua' },
  53. { label: '🇹🇷 Türkiye', value: 'geoip:tr' },
  54. { label: '🇧🇷 Brazil', value: 'geoip:br' },
  55. ];
  56. const DOMAINS_OPTIONS = [
  57. { label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
  58. { label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
  59. { label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
  60. { label: '🇨🇳 China', value: 'geosite:cn' },
  61. { label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
  62. { label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
  63. { label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
  64. { label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
  65. { label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
  66. { label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
  67. ];
  68. const BLOCK_DOMAINS_OPTIONS = [
  69. { label: 'Ads All', value: 'geosite:category-ads-all' },
  70. { label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
  71. { label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
  72. { label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
  73. { label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
  74. { label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
  75. { label: 'Adult +18', value: 'geosite:category-porn' },
  76. { label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
  77. { label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
  78. { label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
  79. { label: '🇨🇳 China', value: 'geosite:cn' },
  80. { label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
  81. { label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
  82. { label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
  83. { label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
  84. { label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
  85. { label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
  86. ];
  87. const SERVICES_OPTIONS = [
  88. { label: 'Apple', value: 'geosite:apple' },
  89. { label: 'Meta', value: 'geosite:meta' },
  90. { label: 'Google', value: 'geosite:google' },
  91. { label: 'OpenAI', value: 'geosite:openai' },
  92. { label: 'Spotify', value: 'geosite:spotify' },
  93. { label: 'Netflix', value: 'geosite:netflix' },
  94. { label: 'Reddit', value: 'geosite:reddit' },
  95. { label: 'Speedtest', value: 'geosite:speedtest' },
  96. ];
  97. // === Routing-rule helpers (matches legacy templateRule{Getter,Setter}) ==
  98. function ruleGetter(outboundTag, property) {
  99. if (!props.templateSettings?.routing?.rules) return [];
  100. const out = [];
  101. for (const rule of props.templateSettings.routing.rules) {
  102. if (
  103. rule
  104. && Object.prototype.hasOwnProperty.call(rule, property)
  105. && Object.prototype.hasOwnProperty.call(rule, 'outboundTag')
  106. && rule.outboundTag === outboundTag
  107. ) {
  108. out.push(...rule[property]);
  109. }
  110. }
  111. return out;
  112. }
  113. function ruleSetter(outboundTag, property, data) {
  114. if (!props.templateSettings?.routing) return;
  115. const current = ruleGetter(outboundTag, property);
  116. if (current.length === 0) {
  117. props.templateSettings.routing.rules.push({
  118. type: 'field',
  119. outboundTag,
  120. [property]: data,
  121. });
  122. return;
  123. }
  124. // Replace the property on the FIRST matching rule and drop any later
  125. // duplicates with the same (outboundTag, property) pair (matches the
  126. // legacy single-write-then-filter behavior).
  127. const next = [];
  128. let inserted = false;
  129. for (const rule of props.templateSettings.routing.rules) {
  130. const matches =
  131. rule
  132. && Object.prototype.hasOwnProperty.call(rule, property)
  133. && Object.prototype.hasOwnProperty.call(rule, 'outboundTag')
  134. && rule.outboundTag === outboundTag;
  135. if (matches) {
  136. if (!inserted && data.length > 0) {
  137. rule[property] = data;
  138. next.push(rule);
  139. inserted = true;
  140. }
  141. } else {
  142. next.push(rule);
  143. }
  144. }
  145. props.templateSettings.routing.rules = next;
  146. }
  147. function syncOutbound(tag, settings) {
  148. // After editing direct/IPv4/warp/nord rules, ensure the matching
  149. // outbound exists when the rule list has any entries, and is
  150. // pruned when none remain (legacy syncRulesWithOutbound).
  151. const t = props.templateSettings;
  152. if (!t) return;
  153. const haveRules = t.routing.rules.some((r) => r?.outboundTag === tag);
  154. const idx = t.outbounds.findIndex((o) => o.tag === tag);
  155. if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
  156. if (haveRules && idx < 0) t.outbounds.push(settings);
  157. }
  158. // === Computed v-models for every Basics field ========================
  159. function rule(tag, property, syncFn) {
  160. return computed({
  161. get: () => ruleGetter(tag, property),
  162. set: (next) => { ruleSetter(tag, property, next); if (syncFn) syncFn(); },
  163. });
  164. }
  165. const directSettings = { tag: 'direct', protocol: 'freedom' };
  166. const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
  167. const freedomStrategy = computed({
  168. get: () => {
  169. const ob = props.templateSettings?.outbounds?.find(
  170. (o) => o.protocol === 'freedom' && o.tag === 'direct',
  171. );
  172. return ob?.settings?.domainStrategy ?? 'AsIs';
  173. },
  174. set: (next) => {
  175. const t = props.templateSettings;
  176. if (!t) return;
  177. const idx = t.outbounds.findIndex((o) => o.protocol === 'freedom' && o.tag === 'direct');
  178. if (idx < 0) {
  179. t.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: { domainStrategy: next } });
  180. } else {
  181. t.outbounds[idx].settings = t.outbounds[idx].settings || {};
  182. t.outbounds[idx].settings.domainStrategy = next;
  183. }
  184. },
  185. });
  186. const routingStrategy = computed({
  187. get: () => props.templateSettings?.routing?.domainStrategy ?? 'AsIs',
  188. set: (next) => { if (props.templateSettings?.routing) props.templateSettings.routing.domainStrategy = next; },
  189. });
  190. function logField(field, fallback) {
  191. return computed({
  192. get: () => props.templateSettings?.log?.[field] ?? fallback,
  193. set: (next) => { if (props.templateSettings?.log) props.templateSettings.log[field] = next; },
  194. });
  195. }
  196. const logLevel = logField('loglevel', 'warning');
  197. const accessLog = logField('access', '');
  198. const errorLog = logField('error', '');
  199. const maskAddressLog = logField('maskAddress', '');
  200. const dnslog = logField('dnsLog', false);
  201. function policyField(field) {
  202. return computed({
  203. get: () => !!props.templateSettings?.policy?.system?.[field],
  204. set: (next) => {
  205. if (!props.templateSettings?.policy?.system) return;
  206. props.templateSettings.policy.system[field] = next;
  207. },
  208. });
  209. }
  210. const statsInboundUplink = policyField('statsInboundUplink');
  211. const statsInboundDownlink = policyField('statsInboundDownlink');
  212. const statsOutboundUplink = policyField('statsOutboundUplink');
  213. const statsOutboundDownlink = policyField('statsOutboundDownlink');
  214. const blockedIPs = rule('blocked', 'ip');
  215. const blockedDomains = rule('blocked', 'domain');
  216. const blockedProtocols = rule('blocked', 'protocol');
  217. const directIPs = rule('direct', 'ip', () => syncOutbound('direct', directSettings));
  218. const directDomains = rule('direct', 'domain', () => syncOutbound('direct', directSettings));
  219. const ipv4Domains = rule('IPv4', 'domain', () => syncOutbound('IPv4', ipv4Settings));
  220. const warpDomains = rule('warp', 'domain');
  221. const nordTag = computed(() => {
  222. const ob = props.templateSettings?.outbounds?.find((o) => o.tag?.startsWith?.('nord-'));
  223. return ob?.tag || 'nord';
  224. });
  225. const nordDomains = computed({
  226. get: () => ruleGetter(nordTag.value, 'domain'),
  227. set: (next) => ruleSetter(nordTag.value, 'domain', next),
  228. });
  229. const torrentSettings = computed({
  230. get: () => BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.value.includes(p)),
  231. set: (next) => {
  232. if (next) {
  233. blockedProtocols.value = [...blockedProtocols.value, ...BITTORRENT_PROTOCOLS];
  234. } else {
  235. blockedProtocols.value = blockedProtocols.value.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
  236. }
  237. },
  238. });
  239. const localOutboundTestUrl = computed({
  240. get: () => props.outboundTestUrl,
  241. set: (next) => emit('update:outbound-test-url', next),
  242. });
  243. </script>
  244. <template>
  245. <a-collapse default-active-key="1">
  246. <a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
  247. <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.generalConfigsDesc')">
  248. <template #icon>
  249. <ExclamationCircleFilled style="color: #FFA031;" />
  250. </template>
  251. </a-alert>
  252. <SettingListItem paddings="small">
  253. <template #title>{{ t('pages.xray.FreedomStrategy') }}</template>
  254. <template #description>{{ t('pages.xray.FreedomStrategyDesc') }}</template>
  255. <template #control>
  256. <a-select v-model:value="freedomStrategy" :style="{ width: '100%' }">
  257. <a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
  258. </a-select>
  259. </template>
  260. </SettingListItem>
  261. <SettingListItem paddings="small">
  262. <template #title>{{ t('pages.xray.RoutingStrategy') }}</template>
  263. <template #description>{{ t('pages.xray.RoutingStrategyDesc') }}</template>
  264. <template #control>
  265. <a-select v-model:value="routingStrategy" :style="{ width: '100%' }">
  266. <a-select-option v-for="s in ROUTING_DOMAIN_STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
  267. </a-select>
  268. </template>
  269. </SettingListItem>
  270. <SettingListItem paddings="small">
  271. <template #title>{{ t('pages.xray.outboundTestUrl') }}</template>
  272. <template #description>{{ t('pages.xray.outboundTestUrlDesc') }}</template>
  273. <template #control>
  274. <a-input v-model:value="localOutboundTestUrl" placeholder="https://www.google.com/generate_204" />
  275. </template>
  276. </SettingListItem>
  277. </a-collapse-panel>
  278. <a-collapse-panel key="2" :header="t('pages.xray.statistics')">
  279. <SettingListItem paddings="small">
  280. <template #title>{{ t('pages.xray.statsInboundUplink') }}</template>
  281. <template #control><a-switch v-model:checked="statsInboundUplink" /></template>
  282. </SettingListItem>
  283. <SettingListItem paddings="small">
  284. <template #title>{{ t('pages.xray.statsInboundDownlink') }}</template>
  285. <template #control><a-switch v-model:checked="statsInboundDownlink" /></template>
  286. </SettingListItem>
  287. <SettingListItem paddings="small">
  288. <template #title>{{ t('pages.xray.statsOutboundUplink') }}</template>
  289. <template #control><a-switch v-model:checked="statsOutboundUplink" /></template>
  290. </SettingListItem>
  291. <SettingListItem paddings="small">
  292. <template #title>Outbound downlink stats</template>
  293. <template #control><a-switch v-model:checked="statsOutboundDownlink" /></template>
  294. </SettingListItem>
  295. </a-collapse-panel>
  296. <a-collapse-panel key="3" :header="t('pages.xray.logConfigs')">
  297. <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.logConfigsDesc')">
  298. <template #icon>
  299. <ExclamationCircleFilled style="color: #FFA031;" />
  300. </template>
  301. </a-alert>
  302. <SettingListItem paddings="small">
  303. <template #title>{{ t('pages.xray.logLevel') }}</template>
  304. <template #description>{{ t('pages.xray.logLevelDesc') }}</template>
  305. <template #control>
  306. <a-select v-model:value="logLevel" :style="{ width: '100%' }">
  307. <a-select-option v-for="s in LOG_LEVELS" :key="s" :value="s">{{ s }}</a-select-option>
  308. </a-select>
  309. </template>
  310. </SettingListItem>
  311. <SettingListItem paddings="small">
  312. <template #title>{{ t('pages.xray.accessLog') }}</template>
  313. <template #description>{{ t('pages.xray.accessLogDesc') }}</template>
  314. <template #control>
  315. <a-select v-model:value="accessLog" :style="{ width: '100%' }">
  316. <a-select-option value="">{{ t('none') }}</a-select-option>
  317. <a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
  318. </a-select>
  319. </template>
  320. </SettingListItem>
  321. <SettingListItem paddings="small">
  322. <template #title>{{ t('pages.xray.errorLog') }}</template>
  323. <template #description>{{ t('pages.xray.errorLogDesc') }}</template>
  324. <template #control>
  325. <a-select v-model:value="errorLog" :style="{ width: '100%' }">
  326. <a-select-option value="">{{ t('none') }}</a-select-option>
  327. <a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
  328. </a-select>
  329. </template>
  330. </SettingListItem>
  331. <SettingListItem paddings="small">
  332. <template #title>{{ t('pages.xray.maskAddress') }}</template>
  333. <template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
  334. <template #control>
  335. <a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
  336. <a-select-option value="">{{ t('none') }}</a-select-option>
  337. <a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
  338. </a-select>
  339. </template>
  340. </SettingListItem>
  341. <SettingListItem paddings="small">
  342. <template #title>{{ t('pages.xray.dnsLog') }}</template>
  343. <template #description>{{ t('pages.xray.dnsLogDesc') }}</template>
  344. <template #control><a-switch v-model:checked="dnslog" /></template>
  345. </SettingListItem>
  346. </a-collapse-panel>
  347. <a-collapse-panel key="4" :header="t('pages.xray.basicRouting')">
  348. <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.blockConnectionsConfigsDesc')">
  349. <template #icon>
  350. <ExclamationCircleFilled style="color: #FFA031;" />
  351. </template>
  352. </a-alert>
  353. <SettingListItem paddings="small">
  354. <template #title>{{ t('pages.xray.Torrent') }}</template>
  355. <template #control><a-switch v-model:checked="torrentSettings" /></template>
  356. </SettingListItem>
  357. <SettingListItem paddings="small">
  358. <template #title>{{ t('pages.xray.blockips') }}</template>
  359. <template #control>
  360. <a-select v-model:value="blockedIPs" mode="tags" :style="{ width: '100%' }">
  361. <a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
  362. }}</a-select-option>
  363. </a-select>
  364. </template>
  365. </SettingListItem>
  366. <SettingListItem paddings="small">
  367. <template #title>{{ t('pages.xray.blockdomains') }}</template>
  368. <template #control>
  369. <a-select v-model:value="blockedDomains" mode="tags" :style="{ width: '100%' }">
  370. <a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{
  371. p.label }}</a-select-option>
  372. </a-select>
  373. </template>
  374. </SettingListItem>
  375. <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.directConnectionsConfigsDesc')">
  376. <template #icon>
  377. <ExclamationCircleFilled style="color: #FFA031;" />
  378. </template>
  379. </a-alert>
  380. <SettingListItem paddings="small">
  381. <template #title>{{ t('pages.xray.directips') }}</template>
  382. <template #control>
  383. <a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
  384. <a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
  385. }}</a-select-option>
  386. </a-select>
  387. </template>
  388. </SettingListItem>
  389. <SettingListItem paddings="small">
  390. <template #title>{{ t('pages.xray.directdomains') }}</template>
  391. <template #control>
  392. <a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
  393. <a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
  394. }}</a-select-option>
  395. </a-select>
  396. </template>
  397. </SettingListItem>
  398. <SettingListItem paddings="small">
  399. <template #title>{{ t('pages.xray.ipv4Routing') }}</template>
  400. <template #description>{{ t('pages.xray.ipv4RoutingDesc') }}</template>
  401. <template #control>
  402. <a-select v-model:value="ipv4Domains" mode="tags" :style="{ width: '100%' }">
  403. <a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
  404. }}</a-select-option>
  405. </a-select>
  406. </template>
  407. </SettingListItem>
  408. <SettingListItem paddings="small">
  409. <template #title>{{ t('pages.xray.warpRouting') }}</template>
  410. <template #description>{{ t('pages.xray.warpRoutingDesc') }}</template>
  411. <template #control>
  412. <a-select v-if="warpExist" v-model:value="warpDomains" mode="tags" :style="{ width: '100%' }">
  413. <a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
  414. }}</a-select-option>
  415. </a-select>
  416. <a-button v-else type="primary" @click="emit('show-warp')">
  417. <template #icon>
  418. <CloudOutlined />
  419. </template>
  420. WARP
  421. </a-button>
  422. </template>
  423. </SettingListItem>
  424. <SettingListItem paddings="small">
  425. <template #title>{{ t('pages.xray.nordRouting') }}</template>
  426. <template #description>{{ t('pages.xray.nordRoutingDesc') }}</template>
  427. <template #control>
  428. <a-select v-if="nordExist" v-model:value="nordDomains" mode="tags" :style="{ width: '100%' }">
  429. <a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
  430. }}</a-select-option>
  431. </a-select>
  432. <a-button v-else type="primary" @click="emit('show-nord')">
  433. <template #icon>
  434. <ApiOutlined />
  435. </template>
  436. NordVPN
  437. </a-button>
  438. </template>
  439. </SettingListItem>
  440. </a-collapse-panel>
  441. <a-collapse-panel key="reset" :header="t('pages.settings.resetDefaultConfig')">
  442. <a-space direction="horizontal" :style="{ padding: '0 20px' }">
  443. <a-button danger @click="confirmResetDefault">
  444. {{ t('pages.settings.resetDefaultConfig') }}
  445. </a-button>
  446. </a-space>
  447. </a-collapse-panel>
  448. </a-collapse>
  449. </template>
  450. <style scoped>
  451. .mb-12 {
  452. margin-bottom: 12px;
  453. }
  454. .hint-alert {
  455. text-align: center;
  456. }
  457. </style>