refactor(frontend): port settings to react+ts

Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.

* models/setting.ts — typed AllSetting class with the same field
  defaults and equals() behavior the vue version had. The .js
  twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
  exposes updateSetting(patch), saveDisabled is derived via useMemo
  off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
  of named slots. The vue twin stays alive because xray (BasicsTab,
  DnsTab) still imports it; deleted when xray migrates.

The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.

SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.

SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
This commit is contained in:
MHSanaei 2026-05-21 21:48:15 +02:00
parent 22e88ec4eb
commit d50ec74b24
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
26 changed files with 2300 additions and 2300 deletions

View file

@ -8,6 +8,6 @@
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/settings.js"></script>
<script type="module" src="/src/entries/settings.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { Col, List, Row } from 'antd';
interface SettingListItemProps {
paddings?: 'small' | 'default';
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
}
export default function SettingListItem({
paddings = 'default',
title,
description,
children,
}: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px';
return (
<List.Item style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<List.Item.Meta title={title} description={description} />
</Col>
<Col xs={24} lg={12}>
{children}
</Col>
</Row>
</List.Item>
);
}

View file

@ -1,23 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import SettingsPage from '@/pages/settings/SettingsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<SettingsPage />
</ThemeProvider>,
);
}
});

View file

@ -0,0 +1,69 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import { AllSetting } from '@/models/setting';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
export function useAllSetting() {
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
const [fetched, setFetched] = useState(false);
const [spinning, setSpinning] = useState(false);
const fetchedRef = useRef(false);
const applyServerState = useCallback((obj: unknown) => {
setAllSetting(new AllSetting(obj));
setOldAllSetting(new AllSetting(obj));
}, []);
const fetchAll = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
if (msg?.success) {
applyServerState(msg.obj);
fetchedRef.current = true;
setFetched(true);
}
}, [applyServerState]);
const saveAll = useCallback(async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
if (msg?.success) await fetchAll();
} finally {
setSpinning(false);
}
}, [allSetting, fetchAll]);
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setAllSetting((prev) => {
const next = new AllSetting(prev);
Object.assign(next, patch);
return next;
});
}, []);
const saveDisabled = useMemo(
() => allSetting.equals(oldAllSetting),
[allSetting, oldAllSetting],
);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchAll();
}, [fetchAll]);
return {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
fetchAll,
saveAll,
};
}

View file

@ -1,108 +0,0 @@
// Mirrors web/assets/js/model/setting.js — every field on this class is
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
// so adding a field here without a matching Go-side change will silently
// drop it on save. Defaults match the legacy panel.
import { ObjectUtil } from '@/utils';
export class AllSetting {
constructor(data) {
this.webListen = "";
this.webDomain = "";
this.webPort = 2053;
this.webCertFile = "";
this.webKeyFile = "";
this.webBasePath = "/";
this.sessionMaxAge = 360;
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
this.pageSize = 25;
this.expireDiff = 0;
this.trafficDiff = 0;
this.remarkModel = "-ieo";
this.datepicker = "gregorian";
this.tgBotEnable = false;
this.tgBotToken = "";
this.tgBotProxy = "";
this.tgBotAPIServer = "";
this.tgBotChatId = "";
this.tgRunTime = "@daily";
this.tgBotBackup = false;
this.tgBotLoginNotify = true;
this.tgCpu = 80;
this.tgLang = "en-US";
this.twoFactorEnable = false;
this.twoFactorToken = "";
this.xrayTemplateConfig = "";
this.subEnable = true;
this.subJsonEnable = false;
this.subTitle = "";
this.subSupportUrl = "";
this.subProfileUrl = "";
this.subAnnounce = "";
this.subEnableRouting = true;
this.subRoutingRules = "";
this.subListen = "";
this.subPort = 2096;
this.subPath = "/sub/";
this.subJsonPath = "/json/";
this.subClashEnable = true;
this.subClashPath = "/clash/";
this.subDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
this.restartXrayOnClientDisable = true;
this.subCertFile = "";
this.subKeyFile = "";
this.subUpdates = 12;
this.subEncrypt = true;
this.subShowInfo = true;
this.subEmailInRemark = true;
this.subURI = "";
this.subJsonURI = "";
this.subClashURI = "";
this.subJsonFragment = "";
this.subJsonNoises = "";
this.subJsonMux = "";
this.subJsonRules = "";
this.timeLocation = "Local";
// LDAP settings
this.ldapEnable = false;
this.ldapHost = "";
this.ldapPort = 389;
this.ldapUseTLS = false;
this.ldapBindDN = "";
this.ldapPassword = "";
this.ldapBaseDN = "";
this.ldapUserFilter = "(objectClass=person)";
this.ldapUserAttr = "mail";
this.ldapVlessField = "vless_enabled";
this.ldapSyncCron = "@every 1m";
this.ldapFlagField = "";
this.ldapTruthyValues = "true,1,yes,on";
this.ldapInvertFlag = false;
this.ldapInboundTags = "";
this.ldapAutoCreate = false;
this.ldapAutoDelete = false;
this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0;
this.hasTgBotToken = false;
this.hasTwoFactorToken = false;
this.hasLdapPassword = false;
this.hasApiToken = false;
this.hasWarpSecret = false;
this.hasNordSecret = false;
if (data == null) {
return
}
ObjectUtil.cloneProps(this, data);
}
equals(other) {
return ObjectUtil.equals(this, other);
}
}

View file

@ -0,0 +1,100 @@
import { ObjectUtil } from '@/utils';
export class AllSetting {
webListen = '';
webDomain = '';
webPort = 2053;
webCertFile = '';
webKeyFile = '';
webBasePath = '/';
sessionMaxAge = 360;
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
remarkModel = '-ieo';
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
tgBotProxy = '';
tgBotAPIServer = '';
tgBotChatId = '';
tgRunTime = '@daily';
tgBotBackup = false;
tgBotLoginNotify = true;
tgCpu = 80;
tgLang = 'en-US';
twoFactorEnable = false;
twoFactorToken = '';
xrayTemplateConfig = '';
subEnable = true;
subJsonEnable = false;
subTitle = '';
subSupportUrl = '';
subProfileUrl = '';
subAnnounce = '';
subEnableRouting = true;
subRoutingRules = '';
subListen = '';
subPort = 2096;
subPath = '/sub/';
subJsonPath = '/json/';
subClashEnable = true;
subClashPath = '/clash/';
subDomain = '';
externalTrafficInformEnable = false;
externalTrafficInformURI = '';
restartXrayOnClientDisable = true;
subCertFile = '';
subKeyFile = '';
subUpdates = 12;
subEncrypt = true;
subShowInfo = true;
subEmailInRemark = true;
subURI = '';
subJsonURI = '';
subClashURI = '';
subJsonFragment = '';
subJsonNoises = '';
subJsonMux = '';
subJsonRules = '';
timeLocation = 'Local';
ldapEnable = false;
ldapHost = '';
ldapPort = 389;
ldapUseTLS = false;
ldapBindDN = '';
ldapPassword = '';
ldapBaseDN = '';
ldapUserFilter = '(objectClass=person)';
ldapUserAttr = 'mail';
ldapVlessField = 'vless_enabled';
ldapSyncCron = '@every 1m';
ldapFlagField = '';
ldapTruthyValues = 'true,1,yes,on';
ldapInvertFlag = false;
ldapInboundTags = '';
ldapAutoCreate = false;
ldapAutoDelete = false;
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
hasTgBotToken = false;
hasTwoFactorToken = false;
hasLdapPassword = false;
hasApiToken = false;
hasWarpSecret = false;
hasNordSecret = false;
constructor(data?: unknown) {
if (data != null) {
ObjectUtil.cloneProps(this, data);
}
}
equals(other: AllSetting): boolean {
return ObjectUtil.equals(this, other);
}
}

View file

@ -0,0 +1,352 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Collapse,
Input,
InputNumber,
Select,
Space,
Switch,
} from 'antd';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import SettingListItem from '@/components/SettingListItem';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
interface GeneralTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
{ name: 'Gregorian (Standard)', value: 'gregorian' },
{ name: 'Jalalian (شمسی)', value: 'jalalian' },
];
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
const { t } = useTranslation();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/list') as ApiMsg<{
tag: string; protocol: string; port: number;
}[]>;
if (cancelled) return;
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})));
} else {
setInboundOptions([]);
}
})();
return () => { cancelled = true; };
}, []);
const remarkModel = useMemo(() => {
const rm = allSetting.remarkModel || '';
return rm.length > 1 ? rm.substring(1).split('') : [];
}, [allSetting.remarkModel]);
const remarkSeparator = useMemo(() => {
const rm = allSetting.remarkModel || '-';
return rm.length > 1 ? rm.charAt(0) : '-';
}, [allSetting.remarkModel]);
const remarkSample = useMemo(() => {
const parts = remarkModel.map((k) => REMARK_MODELS[k]);
return parts.length === 0 ? '' : parts.join(remarkSeparator);
}, [remarkModel, remarkSeparator]);
function setRemarkModel(parts: string[]) {
updateSetting({ remarkModel: remarkSeparator + parts.join('') });
}
function setRemarkSeparator(sep: string) {
const tail = (allSetting.remarkModel || '-').substring(1);
updateSetting({ remarkModel: sep + tail });
}
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
}, [allSetting.ldapInboundTags]);
function setLdapInboundTagList(list: string[]) {
updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' });
}
function onLangChange(value: string) {
setLang(value);
LanguageManager.setLanguage(value);
}
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
})),
[],
);
return (
<Collapse defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.remarkModel')}
description={<>{t('pages.settings.sampleRemark')}: <i>#{remarkSample}</i></>}
>
<Space.Compact style={{ width: '100%' }}>
<Select
mode="multiple"
value={remarkModel}
onChange={setRemarkModel}
style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
/>
<Select
value={remarkSeparator}
onChange={setRemarkSeparator}
style={{ width: '20%' }}
options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s }))}
/>
</Space.Compact>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
<Input value={allSetting.webListen} onChange={(e) => updateSetting({ webListen: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningDomain')} description={t('pages.settings.panelListeningDomainDesc')}>
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
<InputNumber value={allSetting.sessionMaxAge} min={60} style={{ width: '100%' }}
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem
paddings="small"
title="Trusted proxy CIDRs"
description="Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers."
>
<Input
value={allSetting.trustedProxyCIDRs}
placeholder="127.0.0.1/32,::1/128"
onChange={(e) => updateSetting({ trustedProxyCIDRs: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
<InputNumber value={allSetting.pageSize} min={0} step={5} style={{ width: '100%' }}
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
onChange={onLangChange}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: t('pages.settings.notifications'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
<InputNumber value={allSetting.trafficDiff} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
{
key: '3',
label: t('pages.settings.certs'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}>
<Input value={allSetting.webCertFile} onChange={(e) => updateSetting({ webCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.privateKeyPath')} description={t('pages.settings.privateKeyPathDesc')}>
<Input value={allSetting.webKeyFile} onChange={(e) => updateSetting({ webKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '4',
label: t('pages.settings.externalTraffic'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}>
<Switch checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformURI')} description={t('pages.settings.externalTrafficInformURIDesc')}>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
<Switch checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
</SettingListItem>
</>
),
},
{
key: '5',
label: t('pages.settings.dateAndTime'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}>
<Input value={allSetting.timeLocation} onChange={(e) => updateSetting({ timeLocation: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.datepicker')} description={t('pages.settings.datepickerDescription')}>
<Select
value={allSetting.datepicker || 'gregorian'}
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
style={{ width: '100%' }}
options={DATEPICKER_LIST.map((d) => ({ value: d.value, label: d.name }))}
/>
</SettingListItem>
</>
),
},
{
key: '6',
label: 'LDAP',
children: (
<>
<SettingListItem paddings="small" title="Enable LDAP sync">
<Switch checked={allSetting.ldapEnable} onChange={(v) => updateSetting({ ldapEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="LDAP host">
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="LDAP port">
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title="Use TLS (LDAPS)">
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Bind DN">
<Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.'}
>
<Input.Password
value={allSetting.ldapPassword}
placeholder={allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''}
onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title="Base DN">
<Input value={allSetting.ldapBaseDN} onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="User filter">
<Input value={allSetting.ldapUserFilter} onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="User attribute (username/email)">
<Input value={allSetting.ldapUserAttr} onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="VLESS flag attribute">
<Input value={allSetting.ldapVlessField} onChange={(e) => updateSetting({ ldapVlessField: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Generic flag attribute (optional)" description="If set, overrides VLESS flag — e.g. shadowInactive.">
<Input value={allSetting.ldapFlagField} onChange={(e) => updateSetting({ ldapFlagField: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Truthy values" description="Comma-separated; default: true,1,yes,on">
<Input value={allSetting.ldapTruthyValues} onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Invert flag" description="Enable when the attribute means disabled (e.g. shadowInactive).">
<Switch checked={allSetting.ldapInvertFlag} onChange={(v) => updateSetting({ ldapInvertFlag: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Sync schedule" description="Cron-like string, e.g. @every 1m">
<Input value={allSetting.ldapSyncCron} onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Inbound tags" description="Inbounds that LDAP sync may auto-create or auto-delete clients on.">
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">No inbounds found. Create one in Inbounds first.</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title="Auto create clients">
<Switch checked={allSetting.ldapAutoCreate} onChange={(v) => updateSetting({ ldapAutoCreate: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Auto delete clients">
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Default total (GB)">
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title="Default expiry (days)">
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title="Default IP limit">
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
]} />
);
}

View file

@ -1,437 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil, LanguageManager } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
const props = defineProps({
// Reactive AllSetting instance shared with the parent page.
allSetting: { type: Object, required: true },
});
// Remark model legacy stores it as a single string where index 0 is
// the separator char and the rest is the order of model keys
// (i=Inbound, e=Email, o=Other). Surface it as two v-models that read
// and write the underlying string.
const remarkModels = { i: 'Inbound', e: 'Email', o: 'Other' };
const remarkSeparators = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
const remarkModel = computed({
get: () => {
const rm = props.allSetting.remarkModel || '';
return rm.length > 1 ? rm.substring(1).split('') : [];
},
set: (value) => {
const sep = (props.allSetting.remarkModel || '-').charAt(0);
props.allSetting.remarkModel = sep + value.join('');
},
});
const remarkSeparator = computed({
get: () => {
const rm = props.allSetting.remarkModel || '-';
return rm.length > 1 ? rm.charAt(0) : '-';
},
set: (value) => {
const tail = (props.allSetting.remarkModel || '-').substring(1);
props.allSetting.remarkModel = value + tail;
},
});
const remarkSample = computed(() => {
const parts = remarkModel.value.map((k) => remarkModels[k]);
return parts.length === 0 ? '' : parts.join(remarkSeparator.value);
});
const datepicker = computed({
get: () => props.allSetting.datepicker || 'gregorian',
set: (value) => { props.allSetting.datepicker = value; },
});
const datepickerList = [
{ name: 'Gregorian (Standard)', value: 'gregorian' },
{ name: 'Jalalian (شمسی)', value: 'jalalian' },
];
// Language is stored client-side in a cookie, NOT in AllSetting. The
// legacy panel reloads on change so the Go side renders templates in
// the new language.
const lang = ref(LanguageManager.getLanguage());
function onLangChange() {
LanguageManager.setLanguage(lang.value);
}
// LDAP inbound tags are CSV on the wire; expose as an array so the
// multi-select v-model works directly.
const ldapInboundTagList = computed({
get: () => {
const csv = props.allSetting.ldapInboundTags || '';
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
},
set: (list) => {
props.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : '';
},
});
const inboundOptions = ref([]);
async function loadInboundTags() {
const msg = await HttpUtil.get('/panel/api/inbounds/list');
if (msg?.success && Array.isArray(msg.obj)) {
inboundOptions.value = msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
}));
} else {
inboundOptions.value = [];
}
}
onMounted(loadInboundTags);
</script>
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.remarkModel') }}</template>
<template #description>{{ t('pages.settings.sampleRemark') }}: <i>#{{ remarkSample }}</i></template>
<template #control>
<a-input-group :style="{ width: '100%' }">
<a-select v-model:value="remarkModel" mode="multiple"
:style="{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }">
<a-select-option v-for="(label, key) in remarkModels" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
<a-select v-model:value="remarkSeparator" :style="{ width: '20%' }">
<a-select-option v-for="sep in remarkSeparators" :key="sep" :value="sep">{{ sep }}</a-select-option>
</a-select>
</a-input-group>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.panelListeningIP') }}</template>
<template #description>{{ t('pages.settings.panelListeningIPDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webListen" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.panelListeningDomain') }}</template>
<template #description>{{ t('pages.settings.panelListeningDomainDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webDomain" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.panelPort') }}</template>
<template #description>{{ t('pages.settings.panelPortDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.webPort" :min="1" :max="65535" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.panelUrlPath') }}</template>
<template #description>{{ t('pages.settings.panelUrlPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webBasePath" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.sessionMaxAge') }}</template>
<template #description>{{ t('pages.settings.sessionMaxAgeDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.sessionMaxAge" :min="60" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Trusted proxy CIDRs</template>
<template #description>Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.</template>
<template #control>
<a-input v-model:value="allSetting.trustedProxyCIDRs" placeholder="127.0.0.1/32,::1/128" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.pageSize') }}</template>
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.pageSize" :min="0" :step="5" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.language') }}</template>
<template #control>
<a-select v-model:value="lang" :style="{ width: '100%' }" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
:label="l.value">
<span role="img" :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" :header="t('pages.settings.notifications')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.expireTimeDiff') }}</template>
<template #description>{{ t('pages.settings.expireTimeDiffDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.expireDiff" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.trafficDiff') }}</template>
<template #description>{{ t('pages.settings.trafficDiffDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.trafficDiff" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.settings.certs')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.publicKeyPath') }}</template>
<template #description>{{ t('pages.settings.publicKeyPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webCertFile" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.privateKeyPath') }}</template>
<template #description>{{ t('pages.settings.privateKeyPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webKeyFile" type="text" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" :header="t('pages.settings.externalTraffic')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.externalTrafficInformEnable') }}</template>
<template #description>{{ t('pages.settings.externalTrafficInformEnableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.externalTrafficInformEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.externalTrafficInformURI') }}</template>
<template #description>{{ t('pages.settings.externalTrafficInformURIDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.externalTrafficInformURI" placeholder="(http|https)://domain[:port]/path/"
type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.restartXrayOnClientDisable') }}</template>
<template #description>{{ t('pages.settings.restartXrayOnClientDisableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.restartXrayOnClientDisable" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="5" :header="t('pages.settings.dateAndTime')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.timeZone') }}</template>
<template #description>{{ t('pages.settings.timeZoneDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.timeLocation" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.datepicker') }}</template>
<template #description>{{ t('pages.settings.datepickerDescription') }}</template>
<template #control>
<a-select v-model:value="datepicker" :style="{ width: '100%' }">
<a-select-option v-for="item in datepickerList" :key="item.value" :value="item.value">
{{ item.name }}
</a-select-option>
</a-select>
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="6" header="LDAP">
<SettingListItem paddings="small">
<template #title>Enable LDAP sync</template>
<template #control>
<a-switch v-model:checked="allSetting.ldapEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>LDAP host</template>
<template #control>
<a-input v-model:value="allSetting.ldapHost" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>LDAP port</template>
<template #control>
<a-input-number v-model:value="allSetting.ldapPort" :min="1" :max="65535" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Use TLS (LDAPS)</template>
<template #control>
<a-switch v-model:checked="allSetting.ldapUseTLS" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Bind DN</template>
<template #control>
<a-input v-model:value="allSetting.ldapBindDN" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('password') }}</template>
<template #description>
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
</template>
<template #control>
<a-input-password v-model:value="allSetting.ldapPassword"
:placeholder="allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Base DN</template>
<template #control>
<a-input v-model:value="allSetting.ldapBaseDN" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>User filter</template>
<template #control>
<a-input v-model:value="allSetting.ldapUserFilter" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>User attribute (username/email)</template>
<template #control>
<a-input v-model:value="allSetting.ldapUserAttr" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>VLESS flag attribute</template>
<template #control>
<a-input v-model:value="allSetting.ldapVlessField" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Generic flag attribute (optional)</template>
<template #description>If set, overrides VLESS flag e.g. shadowInactive.</template>
<template #control>
<a-input v-model:value="allSetting.ldapFlagField" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Truthy values</template>
<template #description>Comma-separated; default: true,1,yes,on</template>
<template #control>
<a-input v-model:value="allSetting.ldapTruthyValues" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Invert flag</template>
<template #description>Enable when the attribute means disabled (e.g. shadowInactive).</template>
<template #control>
<a-switch v-model:checked="allSetting.ldapInvertFlag" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Sync schedule</template>
<template #description>Cron-like string, e.g. @every 1m</template>
<template #control>
<a-input v-model:value="allSetting.ldapSyncCron" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Inbound tags</template>
<template #description>Inbounds that LDAP sync may auto-create or auto-delete clients on.</template>
<template #control>
<a-select v-model:value="ldapInboundTagList" mode="multiple" :style="{ width: '100%' }">
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<div v-if="inboundOptions.length === 0" class="ldap-no-inbounds">
No inbounds found. Create one in Inbounds first.
</div>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Auto create clients</template>
<template #control>
<a-switch v-model:checked="allSetting.ldapAutoCreate" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Auto delete clients</template>
<template #control>
<a-switch v-model:checked="allSetting.ldapAutoDelete" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Default total (GB)</template>
<template #control>
<a-input-number v-model:value="allSetting.ldapDefaultTotalGB" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Default expiry (days)</template>
<template #control>
<a-input-number v-model:value="allSetting.ldapDefaultExpiryDays" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Default IP limit</template>
<template #control>
<a-input-number v-model:value="allSetting.ldapDefaultLimitIP" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>
</a-collapse>
</template>
<style scoped>
.ldap-no-inbounds {
margin-top: 6px;
color: #999;
font-size: 12px;
}
</style>

View file

@ -0,0 +1,84 @@
.api-token-section {
padding: 8px 20px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.api-token-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.api-token-hint {
margin: 0;
font-size: 12.5px;
opacity: 0.7;
flex: 1;
min-width: 200px;
}
.api-token-row {
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.15s;
}
.api-token-row.disabled {
opacity: 0.55;
}
.api-token-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.api-token-name-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.api-token-name {
font-weight: 600;
font-size: 13.5px;
}
.api-token-created {
font-size: 11px;
opacity: 0.55;
}
.api-token-actions {
display: flex;
align-items: center;
gap: 8px;
}
.api-token-value-wrap {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.api-token-value {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
word-break: break-all;
}

View file

@ -0,0 +1,385 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Collapse,
Empty,
Form,
Input,
List,
Modal,
Space,
Spin,
Switch,
message,
} from 'antd';
import { HttpUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import SettingListItem from '@/components/SettingListItem';
import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css';
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface ApiTokenRow {
id: number;
name: string;
token: string;
enabled: boolean;
createdAt: number;
}
interface SecurityTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
type TfaType = 'set' | 'confirm';
interface TfaState {
open: boolean;
title: string;
description: string;
token: string;
type: TfaType;
onConfirm: (success: boolean, code?: string) => void;
}
const TFA_INITIAL: TfaState = {
open: false,
title: '',
description: '',
token: '',
type: 'set',
onConfirm: () => {},
};
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
const [user, setUser] = useState({
oldUsername: '',
oldPassword: '',
newUsername: '',
newPassword: '',
});
const [updating, setUpdating] = useState(false);
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
const [apiTokensLoading, setApiTokensLoading] = useState(false);
const [visibleTokenIds, setVisibleTokenIds] = useState<Set<number>>(() => new Set());
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [creating, setCreating] = useState(false);
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
setTfa({ ...opts, open: true });
}, []);
const onTfaConfirm = useCallback((success: boolean, code?: string) => {
tfa.onConfirm(success, code);
}, [tfa]);
function updateUserField<K extends keyof typeof user>(key: K, value: string) {
setUser((prev) => ({ ...prev, [key]: value }));
}
const sendUpdateUser = useCallback(async () => {
setUpdating(true);
try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
} finally {
setUpdating(false);
}
}, [user]);
function onUpdateUserClick() {
if (allSetting.twoFactorEnable) {
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: allSetting.twoFactorToken,
type: 'confirm',
onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); },
});
} else {
sendUpdateUser();
}
}
const loadApiTokens = useCallback(async () => {
setApiTokensLoading(true);
try {
const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
setApiTokensLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadApiTokens();
}, [loadApiTokens]);
function toggleTokenVisibility(id: number) {
setVisibleTokenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function copyToken(token: string) {
if (!token) return;
try {
await navigator.clipboard.writeText(token);
message.success(t('copySuccess'));
} catch {
const ta = document.createElement('textarea');
ta.value = token;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
message.success(t('copySuccess'));
}
}
function openCreateModal() {
setCreateName('');
setCreateOpen(true);
}
async function confirmCreateToken() {
const name = createName.trim();
if (!name) {
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return;
}
setCreating(true);
try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ id?: number }>;
if (msg?.success) {
setCreateOpen(false);
await loadApiTokens();
if (msg.obj?.id != null) {
const id = msg.obj.id;
setVisibleTokenIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
}
}
} finally {
setCreating(false);
}
}
function confirmDeleteToken(row: ApiTokenRow) {
modal.confirm({
title: `${t('delete')} "${row.name}"?`,
content: t('pages.settings.security.apiTokenDeleteWarning')
|| 'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg;
if (msg?.success) await loadApiTokens();
},
});
}
async function toggleTokenEnabled(row: ApiTokenRow) {
const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
if (msg?.success) {
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
}
}
function maskToken(token: string): string {
if (!token) return '';
return '•'.repeat(Math.min(token.length, 24));
}
function formatTokenDate(ts: number): string {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
}
function toggleTwoFactor() {
if (!allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: t('pages.settings.security.twoFactorModalSetTitle'),
description: '',
token: newToken,
type: 'set',
onConfirm: (ok: boolean) => {
if (ok) {
message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else {
updateSetting({ twoFactorEnable: false });
}
},
});
} else {
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: allSetting.twoFactorToken,
type: 'confirm',
onConfirm: (ok: boolean) => {
if (!ok) return;
message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
},
});
}
}
return (
<>
{modalContextHolder}
<Collapse defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.security.admin'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
<Input value={user.oldUsername} autoComplete="username"
onChange={(e) => updateUserField('oldUsername', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.currentPassword')}>
<Input.Password value={user.oldPassword} autoComplete="current-password"
onChange={(e) => updateUserField('oldPassword', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newUsername')}>
<Input value={user.newUsername}
onChange={(e) => updateUserField('newUsername', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.newPassword')}>
<Input.Password value={user.newPassword} autoComplete="new-password"
onChange={(e) => updateUserField('newPassword', e.target.value)} />
</SettingListItem>
<List.Item>
<Space direction="horizontal" style={{ padding: '0 20px' }}>
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
{t('confirm')}
</Button>
</Space>
</List.Item>
</>
),
},
{
key: '2',
label: t('pages.settings.security.twoFactor'),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
),
},
{
key: '3',
label: t('pages.nodes.apiToken'),
children: (
<div className="api-token-section">
<div className="api-token-header">
<p className="api-token-hint">{t('pages.nodes.apiTokenHint')}</p>
<Button type="primary" size="small" onClick={openCreateModal}>
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
</Button>
</div>
<Spin spinning={apiTokensLoading}>
{!apiTokens.length && !apiTokensLoading && (
<Empty description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'} />
)}
{apiTokens.map((row) => (
<div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
<div className="api-token-row-head">
<div className="api-token-name-wrap">
<span className="api-token-name">{row.name}</span>
<span className="api-token-created">{formatTokenDate(row.createdAt)}</span>
</div>
<div className="api-token-actions">
<Switch size="small" checked={row.enabled} onChange={() => toggleTokenEnabled(row)} />
<Button size="small" danger type="text" onClick={() => confirmDeleteToken(row)}>
{t('delete')}
</Button>
</div>
</div>
<div className="api-token-value-wrap">
<code className="api-token-value">
{visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)}
</code>
<Button size="small" onClick={() => toggleTokenVisibility(row.id)}>
{visibleTokenIds.has(row.id)
? (t('pages.settings.security.hide') || 'Hide')
: (t('pages.settings.security.show') || 'Show')}
</Button>
<Button size="small" onClick={() => copyToken(row.token)}>{t('copy')}</Button>
</div>
</div>
))}
</Spin>
</div>
),
},
]} />
<Modal
open={createOpen}
title={t('pages.settings.security.apiTokenNew') || 'New API token'}
confirmLoading={creating}
okText={t('confirm')}
cancelText={t('cancel')}
onOk={confirmCreateToken}
onCancel={() => setCreateOpen(false)}
>
<Form layout="vertical">
<Form.Item label={t('pages.settings.security.apiTokenName') || 'Name'} required>
<Input
value={createName}
maxLength={64}
placeholder={t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'}
onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreateToken}
/>
</Form.Item>
</Form>
</Modal>
<TwoFactorModal
open={tfa.open}
title={tfa.title}
description={tfa.description}
token={tfa.token}
type={tfa.type}
onConfirm={onTfaConfirm}
onOpenChange={(open) => setTfa((prev) => ({ ...prev, open }))}
/>
</>
);
}

View file

@ -1,404 +0,0 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { HttpUtil, RandomUtil } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue';
import TwoFactorModal from './TwoFactorModal.vue';
const { t } = useI18n();
const props = defineProps({
allSetting: { type: Object, required: true },
});
// 2FA modal state both the "set" (enabling) and "confirm" (changing
// password / disabling) flows route through the same component.
const tfa = reactive({
open: false,
title: '',
description: '',
token: '',
type: 'set',
// resolveConfirm is called by the modal's @confirm with the success bool;
// it then routes the value back to whichever flow opened the modal.
resolveConfirm: (_success) => { },
});
function openTfa({ title, description = '', token = '', type, onConfirm }) {
tfa.title = title;
tfa.description = description;
tfa.token = token;
tfa.type = type;
tfa.resolveConfirm = onConfirm;
tfa.open = true;
}
function onTfaConfirm(success) {
tfa.resolveConfirm(success);
}
const user = reactive({
oldUsername: '',
oldPassword: '',
newUsername: '',
newPassword: '',
});
const updating = ref(false);
async function sendUpdateUser() {
updating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
} finally {
updating.value = false;
}
}
function updateUser() {
if (props.allSetting.twoFactorEnable) {
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: props.allSetting.twoFactorToken,
type: 'confirm',
onConfirm: (ok) => { if (ok) sendUpdateUser(); },
});
} else {
sendUpdateUser();
}
}
const apiTokens = ref([]);
const apiTokensLoading = ref(false);
const visibleTokenIds = ref(new Set());
const createOpen = ref(false);
const createName = ref('');
const creating = ref(false);
async function loadApiTokens() {
apiTokensLoading.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/apiTokens');
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
} finally {
apiTokensLoading.value = false;
}
}
function isTokenVisible(id) {
return visibleTokenIds.value.has(id);
}
function toggleTokenVisibility(id) {
const next = new Set(visibleTokenIds.value);
if (next.has(id)) next.delete(id); else next.add(id);
visibleTokenIds.value = next;
}
async function copyToken(token) {
if (!token) return;
try {
await navigator.clipboard.writeText(token);
message.success(t('copySuccess'));
} catch (_e) {
const ta = document.createElement('textarea');
ta.value = token;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
message.success(t('copySuccess'));
}
}
function openCreateModal() {
createName.value = '';
createOpen.value = true;
}
async function confirmCreateToken() {
const name = createName.value.trim();
if (!name) {
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return;
}
creating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
if (msg?.success) {
createOpen.value = false;
await loadApiTokens();
if (msg.obj?.id != null) {
const next = new Set(visibleTokenIds.value);
next.add(msg.obj.id);
visibleTokenIds.value = next;
}
}
} finally {
creating.value = false;
}
}
function confirmDeleteToken(row) {
Modal.confirm({
title: `${t('delete')} "${row.name}"?`,
content: t('pages.settings.security.apiTokenDeleteWarning')
|| 'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
if (msg?.success) await loadApiTokens();
},
});
}
async function toggleTokenEnabled(row) {
const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
if (msg?.success) row.enabled = target;
}
function maskToken(token) {
if (!token) return '';
return '•'.repeat(Math.min(token.length, 24));
}
function formatTokenDate(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
}
onMounted(loadApiTokens);
function toggleTwoFactor() {
// Switch read-only the actual flip happens after the modal succeeds.
if (!props.allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: t('pages.settings.security.twoFactorModalSetTitle'),
token: newToken,
type: 'set',
onConfirm: (ok) => {
if (ok) {
message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
props.allSetting.twoFactorToken = newToken;
}
props.allSetting.twoFactorEnable = ok;
},
});
} else {
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: props.allSetting.twoFactorToken,
type: 'confirm',
onConfirm: (ok) => {
if (!ok) return;
message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
props.allSetting.twoFactorEnable = false;
props.allSetting.twoFactorToken = '';
},
});
}
}
</script>
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" :header="t('pages.settings.security.admin')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.oldUsername') }}</template>
<template #control>
<a-input v-model:value="user.oldUsername" autocomplete="username" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.currentPassword') }}</template>
<template #control>
<a-input-password v-model:value="user.oldPassword" autocomplete="current-password" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.newUsername') }}</template>
<template #control>
<a-input v-model:value="user.newUsername" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.newPassword') }}</template>
<template #control>
<a-input-password v-model:value="user.newPassword" autocomplete="new-password" />
</template>
</SettingListItem>
<a-list-item>
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button type="primary" :loading="updating" @click="updateUser">{{ t('confirm') }}</a-button>
</a-space>
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="2" :header="t('pages.settings.security.twoFactor')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.security.twoFactorEnable') }}</template>
<template #description>{{ t('pages.settings.security.twoFactorEnableDesc') }}</template>
<template #control>
<a-switch :checked="allSetting.twoFactorEnable" @click="toggleTwoFactor" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
<div class="api-token-section">
<div class="api-token-header">
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
<a-button type="primary" size="small" @click="openCreateModal">
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
</a-button>
</div>
<a-spin :spinning="apiTokensLoading">
<a-empty v-if="!apiTokens.length && !apiTokensLoading"
:description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
<div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
<div class="api-token-row-head">
<div class="api-token-name-wrap">
<span class="api-token-name">{{ row.name }}</span>
<span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
</div>
<div class="api-token-actions">
<a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
<a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
{{ t('delete') }}
</a-button>
</div>
</div>
<div class="api-token-value-wrap">
<code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
<a-button size="small" @click="toggleTokenVisibility(row.id)">
{{ isTokenVisible(row.id)
? (t('pages.settings.security.hide') || 'Hide')
: (t('pages.settings.security.show') || 'Show') }}
</a-button>
<a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
</div>
</div>
</a-spin>
</div>
</a-collapse-panel>
</a-collapse>
<a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
:confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
<a-form layout="vertical">
<a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
<a-input v-model:value="createName" maxlength="64"
:placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
@keyup.enter="confirmCreateToken" />
</a-form-item>
</a-form>
</a-modal>
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
:type="tfa.type" @confirm="onTfaConfirm" />
</template>
<style scoped>
.api-token-section {
padding: 8px 20px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.api-token-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.api-token-hint {
margin: 0;
font-size: 12.5px;
opacity: 0.7;
flex: 1;
min-width: 200px;
}
.api-token-row {
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.15s;
}
.api-token-row.disabled {
opacity: 0.55;
}
.api-token-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.api-token-name-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.api-token-name {
font-weight: 600;
font-size: 13.5px;
}
.api-token-created {
font-size: 11px;
opacity: 0.55;
}
.api-token-actions {
display: flex;
align-items: center;
gap: 8px;
}
.api-token-value-wrap {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.api-token-value {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
word-break: break-all;
}
</style>

View file

@ -0,0 +1,87 @@
.settings-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.settings-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.settings-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.settings-page .ant-layout,
.settings-page .ant-layout-content {
background: transparent;
}
.settings-page .content-shell {
background: transparent;
}
.settings-page .content-area {
padding: 24px;
}
.settings-page .loading-spacer {
min-height: calc(100vh - 120px);
}
.settings-page .conf-alert {
margin-bottom: 10px;
}
.settings-page .header-row {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.settings-page .header-actions {
padding: 4px;
}
.settings-page .header-info {
display: flex;
justify-content: flex-end;
}
.icons-only .ant-tabs-nav {
margin-bottom: 8px;
}
.icons-only .ant-tabs-nav-wrap {
width: 100%;
}
.icons-only .ant-tabs-nav-list {
display: flex;
width: 100%;
}
.icons-only .ant-tabs-tab {
flex: 1 1 0;
justify-content: center;
margin: 0;
padding: 10px 0;
}
.icons-only .ant-tabs-tab .anticon {
margin: 0;
font-size: 18px;
}
.icons-only .ant-tabs-nav-operations {
display: none;
}
.ldap-no-inbounds {
margin-top: 6px;
color: #999;
font-size: 12px;
}

View file

@ -0,0 +1,331 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
BackTop,
Button,
Card,
Col,
ConfigProvider,
Layout,
Modal,
Row,
Space,
Spin,
Tabs,
Tooltip,
} from 'antd';
import {
CloudServerOutlined,
CodeOutlined,
MessageOutlined,
SafetyOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { HttpUtil, PromiseUtil } from '@/utils';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useAllSetting } from '@/hooks/useAllSetting';
import AppSidebar from '@/components/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
import TelegramTab from './TelegramTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import './SettingsPage.css';
interface ApiMsg {
success?: boolean;
}
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
function slugToKey(slug: string): string {
const i = tabSlugs.indexOf(slug);
return i >= 0 ? String(i + 1) : '1';
}
function keyToSlug(key: string): string {
return tabSlugs[Number(key) - 1] || tabSlugs[0];
}
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
const v4 = h.split('.');
if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return true;
if (!h.includes(':') || h.includes(':::')) return false;
const parts = h.split('::');
if (parts.length > 2) return false;
const split = (s: string) => (s ? s.split(':').filter(Boolean) : []);
const head = split(parts[0]);
const tail = split(parts[1]);
const valid = (seg: string) => /^[0-9a-fA-F]{1,4}$/.test(seg);
if (![...head, ...tail].every(valid)) return false;
const groups = head.length + tail.length;
return parts.length === 2 ? groups < 8 : groups === 8;
}
function scrollTarget() {
return document.getElementById('content-layout') as HTMLElement;
}
export default function SettingsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
saveAll,
} = useAllSetting();
const [entryHost, setEntryHost] = useState('');
const [entryPort, setEntryPort] = useState('');
const [entryIsIP, setEntryIsIP] = useState(false);
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
const host = window.location.hostname;
setEntryHost(host);
setEntryPort(window.location.port);
setEntryIsIP(isIp(host));
/* eslint-enable react-hooks/set-state-in-effect */
}, []);
const [alertVisible, setAlertVisible] = useState(true);
const [activeTabKey, setActiveTabKey] = useState<string>(() => slugToKey(window.location.hash.slice(1)));
useEffect(() => {
const onHashChange = () => setActiveTabKey(slugToKey(window.location.hash.slice(1)));
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
function onTabChange(key: string) {
setActiveTabKey(key);
const slug = keyToSlug(key);
if (window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function rebuildUrlAfterRestart(): string {
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:';
let base = webBasePath ? webBasePath.replace(/^\//, '') : '';
if (base && !base.endsWith('/')) base += '/';
if (!entryIsIP) {
const url = new URL(window.location.href);
url.pathname = `/${base}panel/settings`;
url.protocol = newProtocol;
return url.toString();
}
let finalHost = entryHost;
let finalPort = entryPort || '';
if (webDomain && isIp(webDomain)) finalHost = webDomain;
if (webPort && Number(webPort) !== Number(entryPort)) finalPort = String(webPort);
const url = new URL(`${newProtocol}//${finalHost}`);
if (finalPort) url.port = finalPort;
url.pathname = `/${base}panel/settings`;
return url.toString();
}
function restartPanel() {
modal.confirm({
title: t('pages.settings.restartPanel'),
content: t('pages.settings.restartPanelDesc'),
okText: t('pages.settings.restartPanel'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg;
if (!msg?.success) return;
await PromiseUtil.sleep(5000);
window.location.replace(rebuildUrlAfterRestart());
} finally {
setSpinning(false);
}
},
});
}
const confAlerts = useMemo<string[]>(() => {
const out: string[] = [];
if (window.location.protocol !== 'https:') {
out.push('Panel is served over plain HTTP — set up TLS for production.');
}
if (allSetting.webPort === 2053) {
out.push('Default port 2053 is well-known — change it to a random port.');
}
const segs = window.location.pathname.split('/').length < 4;
if (segs && allSetting.webBasePath === '/') {
out.push('Default base path "/" is well-known — change it to a random path.');
}
if (allSetting.subEnable) {
let subPath = allSetting.subPath;
if (allSetting.subURI) {
try { subPath = new URL(allSetting.subURI).pathname; } catch { /* noop */ }
}
if (subPath === '/sub/') {
out.push('Default subscription path "/sub/" is well-known — change it.');
}
}
if (allSetting.subJsonEnable) {
let p = allSetting.subJsonPath;
if (allSetting.subJsonURI) {
try { p = new URL(allSetting.subJsonURI).pathname; } catch { /* noop */ }
}
if (p === '/json/') {
out.push('Default JSON subscription path "/json/" is well-known — change it.');
}
}
return out;
}, [allSetting]);
const pageClass = useMemo(() => {
const classes = ['settings-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const tabItems = useMemo(() => {
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [
{
key: '1',
label: (
<Tooltip title={isMobile ? t('pages.settings.panelSettings') : null}>
<span><SettingOutlined />{!isMobile && <> {t('pages.settings.panelSettings')}</>}</span>
</Tooltip>
),
children: <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />,
},
{
key: '2',
label: (
<Tooltip title={isMobile ? t('pages.settings.securitySettings') : null}>
<span><SafetyOutlined />{!isMobile && <> {t('pages.settings.securitySettings')}</>}</span>
</Tooltip>
),
children: <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />,
},
{
key: '3',
label: (
<Tooltip title={isMobile ? t('pages.settings.TGBotSettings') : null}>
<span><MessageOutlined />{!isMobile && <> {t('pages.settings.TGBotSettings')}</>}</span>
</Tooltip>
),
children: <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />,
},
{
key: '4',
label: (
<Tooltip title={isMobile ? t('pages.settings.subSettings') : null}>
<span><CloudServerOutlined />{!isMobile && <> {t('pages.settings.subSettings')}</>}</span>
</Tooltip>
),
children: <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />,
},
];
if (allSetting.subJsonEnable || allSetting.subClashEnable) {
items.push({
key: '5',
label: (
<Tooltip title={isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null}>
<span><CodeOutlined />{!isMobile && <> {t('pages.settings.subSettings')} (Formats)</>}</span>
</Tooltip>
),
children: <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />,
});
}
return items;
}, [allSetting, updateSetting, isMobile, t]);
return (
<ConfigProvider theme={antdThemeConfig}>
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={spinning || !fetched} delay={200} tip="Loading…" size="large">
{!fetched ? (
<div className="loading-spacer" />
) : (
<>
{confAlerts.length > 0 && alertVisible && (
<Alert
type="error"
showIcon
closable
className="conf-alert"
onClose={() => setAlertVisible(false)}
message="Security warnings"
description={(
<>
<b>Your panel may be exposed:</b>
<ul>
{confAlerts.map((msg, i) => <li key={i}>{msg}</li>)}
</ul>
</>
)}
/>
)}
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
<Col span={24}>
<Card hoverable>
<Row className="header-row">
<Col xs={24} sm={10} className="header-actions">
<Space direction="horizontal">
<Button type="primary" disabled={saveDisabled} onClick={saveAll}>
{t('pages.settings.save')}
</Button>
<Button type="primary" danger disabled={!saveDisabled} onClick={restartPanel}>
{t('pages.settings.restartPanel')}
</Button>
</Space>
</Col>
<Col xs={24} sm={14} className="header-info">
<BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon message={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
className={isMobile ? 'icons-only' : ''}
items={tabItems}
/>
</Col>
</Row>
</>
)}
</Spin>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}

View file

@ -1,375 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal } from 'ant-design-vue';
import {
SettingOutlined,
SafetyOutlined,
MessageOutlined,
CloudServerOutlined,
CodeOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import { useAllSetting } from './useAllSetting.js';
import GeneralTab from './GeneralTab.vue';
import SecurityTab from './SecurityTab.vue';
import TelegramTab from './TelegramTab.vue';
import SubscriptionGeneralTab from './SubscriptionGeneralTab.vue';
import SubscriptionFormatsTab from './SubscriptionFormatsTab.vue';
const { t } = useI18n();
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the
// scrolled element. Inline-arrow `() => document.getElementById(...)`
// in the template threw "Cannot read properties of undefined (reading
// 'getElementById')" because of how Vue 3 evaluates the expression
// outside the script-setup scope wrap in a regular function so
// `document` resolves to the window global at call time.
function scrollTarget() {
return document.getElementById('content-layout');
}
// `entry*` mirrors the URL the user opened the panel with so the page
// can rebuild it after a restart that may change host/port/scheme.
const entryHost = ref('');
const entryPort = ref('');
const entryIsIP = ref(false);
function isIp(h) {
if (typeof h !== 'string') return false;
// IPv4: four dot-separated octets 0-255.
const v4 = h.split('.');
if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return true;
// IPv6: hex groups, optional single :: compression.
if (!h.includes(':') || h.includes(':::')) return false;
const parts = h.split('::');
if (parts.length > 2) return false;
const split = (s) => (s ? s.split(':').filter(Boolean) : []);
const head = split(parts[0]);
const tail = split(parts[1]);
const valid = (seg) => /^[0-9a-fA-F]{1,4}$/.test(seg);
if (![...head, ...tail].every(valid)) return false;
const groups = head.length + tail.length;
return parts.length === 2 ? groups < 8 : groups === 8;
}
onMounted(() => {
entryHost.value = window.location.hostname;
entryPort.value = window.location.port;
entryIsIP.value = isIp(entryHost.value);
});
// Rebuild the URL after a restart host/port/scheme may have changed
// (cert toggled on, port edited, base path edited).
function rebuildUrlAfterRestart() {
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:';
let base = webBasePath ? webBasePath.replace(/^\//, '') : '';
if (base && !base.endsWith('/')) base += '/';
if (!entryIsIP.value) {
const url = new URL(window.location.href);
url.pathname = `/${base}panel/settings`;
url.protocol = newProtocol;
return url.toString();
}
let finalHost = entryHost.value;
let finalPort = entryPort.value || '';
if (webDomain && isIp(webDomain)) finalHost = webDomain;
if (webPort && Number(webPort) !== Number(entryPort.value)) finalPort = String(webPort);
const url = new URL(`${newProtocol}//${finalHost}`);
if (finalPort) url.port = finalPort;
url.pathname = `/${base}panel/settings`;
return url.toString();
}
function restartPanel() {
Modal.confirm({
title: t('pages.settings.restartPanel'),
content: t('pages.settings.restartPanelDesc'),
okText: t('pages.settings.restartPanel'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
async onOk() {
spinning.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/restartPanel');
if (!msg?.success) return;
await PromiseUtil.sleep(5000);
window.location.replace(rebuildUrlAfterRestart());
} finally {
spinning.value = false;
}
},
});
}
// Conf alerts mirror the legacy banner pure derivation off allSetting.
const confAlerts = computed(() => {
const out = [];
if (window.location.protocol !== 'https:') {
out.push('Panel is served over plain HTTP — set up TLS for production.');
}
if (allSetting.webPort === 2053) {
out.push('Default port 2053 is well-known — change it to a random port.');
}
const segs = window.location.pathname.split('/').length < 4;
if (segs && allSetting.webBasePath === '/') {
out.push('Default base path "/" is well-known — change it to a random path.');
}
if (allSetting.subEnable) {
let subPath = allSetting.subPath;
if (allSetting.subURI) {
try { subPath = new URL(allSetting.subURI).pathname; } catch (_e) { }
}
if (subPath === '/sub/') {
out.push('Default subscription path "/sub/" is well-known — change it.');
}
}
if (allSetting.subJsonEnable) {
let p = allSetting.subJsonPath;
if (allSetting.subJsonURI) {
try { p = new URL(allSetting.subJsonURI).pathname; } catch (_e) { }
}
if (p === '/json/') {
out.push('Default JSON subscription path "/json/" is well-known — change it.');
}
}
return out;
});
const alertVisible = ref(true);
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
const slugToKey = (slug) => {
const i = tabSlugs.indexOf(slug);
return i >= 0 ? String(i + 1) : '1';
};
const keyToSlug = (key) => tabSlugs[Number(key) - 1] || tabSlugs[0];
const activeTabKey = ref(slugToKey(window.location.hash.slice(1)));
function onTabChange(key) {
activeTabKey.value = key;
const slug = keyToSlug(key);
if (window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function syncTabFromHash() {
activeTabKey.value = slugToKey(window.location.hash.slice(1));
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="settings-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<template v-else>
<a-alert v-if="confAlerts.length > 0 && alertVisible" type="error" show-icon closable class="conf-alert"
@close="alertVisible = false">
<template #message>Security warnings</template>
<template #description>
<b>Your panel may be exposed:</b>
<ul>
<li v-for="(msg, i) in confAlerts" :key="i">{{ msg }}</li>
</ul>
</template>
</a-alert>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<a-col :span="24">
<a-card hoverable>
<a-row class="header-row">
<a-col :xs="24" :sm="10" class="header-actions">
<a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="saveAll">
{{ t('pages.settings.save') }}
</a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="restartPanel">
{{ t('pages.settings.restartPanel') }}
</a-button>
</a-space>
</a-col>
<a-col :xs="24" :sm="14" class="header-info">
<a-back-top :target="scrollTarget" :visibility-height="200" />
<a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :span="24">
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
<a-tab-pane key="1" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
<SettingOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
</template>
<GeneralTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane key="2" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
<SafetyOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
</template>
<SecurityTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane key="3" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
<MessageOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
</template>
<TelegramTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane key="4" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
<CloudServerOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
</template>
<SubscriptionGeneralTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
<CodeOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
</template>
<SubscriptionFormatsTab :all-setting="allSetting" />
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</template>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
</a-config-provider>
</template>
<style scoped>
.settings-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.settings-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.settings-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.settings-page :deep(.ant-layout),
.settings-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
.conf-alert {
margin-bottom: 10px;
}
.header-row {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.header-actions {
padding: 4px;
}
.header-info {
display: flex;
justify-content: flex-end;
}
.tab-pane {
padding-top: 20px;
}
.icons-only :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
.icons-only :deep(.ant-tabs-nav-wrap) {
width: 100%;
}
.icons-only :deep(.ant-tabs-nav-list) {
display: flex;
width: 100%;
}
.icons-only :deep(.ant-tabs-tab) {
flex: 1 1 0;
justify-content: center;
margin: 0;
padding: 10px 0;
}
.icons-only :deep(.ant-tabs-tab .anticon) {
margin: 0;
font-size: 18px;
}
.icons-only :deep(.ant-tabs-nav-operations) {
display: none;
}
</style>

View file

@ -0,0 +1,4 @@
.nested-block {
padding: 10px 20px;
display: block !important;
}

View file

@ -0,0 +1,434 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Collapse,
Input,
InputNumber,
List,
Select,
Space,
Switch,
} from 'antd';
import type { AllSetting } from '@/models/setting';
import SettingListItem from '@/components/SettingListItem';
import './SubscriptionFormatsTab.css';
interface SubscriptionFormatsTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
const DEFAULT_FRAGMENT = {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
};
const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
];
const DEFAULT_MUX = {
enabled: true,
concurrency: 8,
xudpConcurrency: 16,
xudpProxyUDP443: 'reject',
};
const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
{ type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
{ type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
];
const directIPsOptions = [
{ label: 'Private IP', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const directDomainsOptions = [
{ label: 'Private DNS', value: 'geosite:private' },
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
];
function sanitizePath(input: string): string {
return String(input ?? '').replace(/[:*]/g, '');
}
function normalizePath(input: string): string {
let p = input || '/';
if (!p.startsWith('/')) p = '/' + p;
if (!p.endsWith('/')) p += '/';
p = p.replace(/\/+/g, '/');
return p;
}
function readJson<T>(raw: string, fallback: T): T {
try {
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) {
const { t } = useTranslation();
const fragment = allSetting.subJsonFragment !== '';
const noisesEnabled = allSetting.subJsonNoises !== '';
const muxEnabled = allSetting.subJsonMux !== '';
const directEnabled = allSetting.subJsonRules !== '';
const fragmentObj = useMemo(
() => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
[allSetting.subJsonFragment, fragment],
);
function setFragmentEnabled(v: boolean) {
updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
}
function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
if (value === '') return;
const next = { ...fragmentObj, [key]: value };
updateSetting({ subJsonFragment: JSON.stringify(next) });
}
const noisesArray = useMemo(
() => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
[allSetting.subJsonNoises, noisesEnabled],
);
function setNoisesEnabled(v: boolean) {
updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
}
function setNoisesArray(next: typeof DEFAULT_NOISES) {
if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
}
function addNoise() {
setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
}
function removeNoise(index: number) {
const next = [...noisesArray];
next.splice(index, 1);
setNoisesArray(next);
}
function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
const next = [...noisesArray];
next[index] = { ...next[index], [field]: value };
setNoisesArray(next);
}
const muxObj = useMemo(
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
[allSetting.subJsonMux, muxEnabled],
);
function setMuxEnabled(v: boolean) {
updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' });
}
function setMuxField<K extends keyof typeof DEFAULT_MUX>(key: K, value: typeof DEFAULT_MUX[K]) {
const next = { ...muxObj, [key]: value };
updateSetting({ subJsonMux: JSON.stringify(next) });
}
const ruleArray = useMemo(() => {
if (!directEnabled) return null;
return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
}, [allSetting.subJsonRules, directEnabled]);
const directIPs = useMemo(() => {
if (!ruleArray) return [];
const ipRule = ruleArray.find((r) => r.ip);
return ipRule?.ip ?? [];
}, [ruleArray]);
const directDomains = useMemo(() => {
if (!ruleArray) return [];
const dRule = ruleArray.find((r) => r.domain);
return dRule?.domain ?? [];
}, [ruleArray]);
function setDirectEnabled(v: boolean) {
updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
}
function setDirectIPs(value: string[]) {
if (!ruleArray) return;
let rules = [...ruleArray];
if (value.length === 0) {
rules = rules.filter((r) => !r.ip);
} else {
let idx = rules.findIndex((r) => r.ip);
if (idx === -1) {
rules.push({ ...DEFAULT_RULES[1] });
idx = rules.length - 1;
}
rules[idx] = { ...rules[idx], ip: [...value] };
}
updateSetting({ subJsonRules: JSON.stringify(rules) });
}
function setDirectDomains(value: string[]) {
if (!ruleArray) return;
let rules = [...ruleArray];
if (value.length === 0) {
rules = rules.filter((r) => !r.domain);
} else {
let idx = rules.findIndex((r) => r.domain);
if (idx === -1) {
rules.push({ ...DEFAULT_RULES[0] });
idx = rules.length - 1;
}
rules[idx] = { ...rules[idx], domain: [...value] };
}
updateSetting({ subJsonRules: JSON.stringify(rules) });
}
return (
<Collapse defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
children: (
<>
{allSetting.subJsonEnable && (
<>
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subJsonPath}
placeholder="/json/"
onChange={(e) => updateSetting({ subJsonPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subURI')}</>} description={t('pages.settings.subURIDesc')}>
<Input
value={allSetting.subJsonURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
/>
</SettingListItem>
</>
)}
{allSetting.subClashEnable && (
<>
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subClashPath}
placeholder="/clash/"
onChange={(e) => updateSetting({ subClashPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subURI')}</>} description={t('pages.settings.subURIDesc')}>
<Input
value={allSetting.subClashURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
/>
</SettingListItem>
</>
)}
</>
),
},
{
key: '2',
label: t('pages.settings.fragment'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
<Switch checked={fragment} onChange={setFragmentEnabled} />
</SettingListItem>
{fragment && (
<List.Item className="nested-block">
<Collapse items={[
{
key: 'sett',
label: t('pages.settings.fragmentSett'),
children: (
<>
<SettingListItem paddings="small" title="Packets">
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
onChange={(e) => setFragmentField('packets', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title="Length">
<Input value={fragmentObj.length} placeholder="100-200"
onChange={(e) => setFragmentField('length', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title="Interval">
<Input value={fragmentObj.interval} placeholder="10-20"
onChange={(e) => setFragmentField('interval', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title="Max split">
<Input value={fragmentObj.maxSplit} placeholder="300-400"
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
</SettingListItem>
</>
),
},
]} />
</List.Item>
)}
</>
),
},
{
key: '3',
label: 'Noises',
children: (
<>
<SettingListItem paddings="small" title="Noises" description={t('pages.settings.noisesDesc')}>
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
</SettingListItem>
{noisesEnabled && (
<List.Item className="nested-block">
<Collapse items={noisesArray.map((noise, index) => ({
key: String(index),
label: `Noise №${index + 1}`,
children: (
<>
<SettingListItem paddings="small" title="Type">
<Select
value={noise.type}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'type', v)}
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title="Packet">
<Input value={noise.packet} placeholder="5-10"
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title="Delay (ms)">
<Input value={noise.delay} placeholder="10-20"
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title="Apply to">
<Select
value={noise.applyTo}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<Space direction="horizontal" style={{ padding: '10px 20px' }}>
{noisesArray.length > 1 && (
<Button type="primary" danger onClick={() => removeNoise(index)}>
{t('delete')}
</Button>
)}
</Space>
</>
),
}))} />
<Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>+ Noise</Button>
</List.Item>
)}
</>
),
},
{
key: '4',
label: t('pages.settings.mux'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.mux')} description={t('pages.settings.muxDesc')}>
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem>
{muxEnabled && (
<List.Item className="nested-block">
<Collapse items={[
{
key: 'sett',
label: t('pages.settings.muxSett'),
children: (
<>
<SettingListItem paddings="small" title="Concurrency">
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
</SettingListItem>
<SettingListItem paddings="small" title="xudp concurrency">
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
</SettingListItem>
<SettingListItem paddings="small" title="xudp UDP 443">
<Select
value={muxObj.xudpProxyUDP443}
style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</>
),
},
]} />
</List.Item>
)}
</>
),
},
{
key: '5',
label: t('pages.settings.direct'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.direct')} description={t('pages.settings.directDesc')}>
<Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem>
{directEnabled && (
<List.Item className="nested-block">
<Collapse items={[
{
key: 'rules',
label: t('pages.settings.direct'),
children: (
<>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
onChange={setDirectIPs}
options={directIPsOptions}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} {t('domainName')}</>}>
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
onChange={setDirectDomains}
options={directDomainsOptions}
/>
</SettingListItem>
</>
),
},
]} />
</List.Item>
)}
</>
),
},
]} />
);
}

View file

@ -1,433 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
const props = defineProps({
allSetting: { type: Object, required: true },
});
// === Defaults (match legacy) ============================================
const DEFAULT_FRAGMENT = {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
};
const DEFAULT_NOISES = [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }];
const DEFAULT_MUX = {
enabled: true,
concurrency: 8,
xudpConcurrency: 16,
xudpProxyUDP443: 'reject',
};
const DEFAULT_RULES = [
{ type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
{ type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
];
const directIPsOptions = [
{ label: 'Private IP', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const directDomainsOptions = [
{ label: 'Private DNS', value: 'geosite:private' },
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
];
// === Path helpers (json + clash share the same shape) ===================
function makePath(field) {
return computed({
get: () => props.allSetting[field],
set: (v) => {
props.allSetting[field] = String(v ?? '').replace(/[:*]/g, '');
},
});
}
function normalizePath(field) {
let p = props.allSetting[field] || '/';
if (!p.startsWith('/')) p = '/' + p;
if (!p.endsWith('/')) p += '/';
p = p.replace(/\/+/g, '/');
props.allSetting[field] = p;
}
const subJsonPath = makePath('subJsonPath');
const subClashPath = makePath('subClashPath');
// === Fragment ===========================================================
// `subJsonFragment` is a JSON-encoded object when enabled, "" when off.
function readJson(field, fallback) {
try {
const raw = props.allSetting[field];
if (!raw) return fallback;
return JSON.parse(raw);
} catch (_e) {
return fallback;
}
}
function writeJson(field, value) {
props.allSetting[field] = JSON.stringify(value);
}
const fragment = computed({
get: () => props.allSetting.subJsonFragment !== '',
set: (v) => {
props.allSetting.subJsonFragment = v ? JSON.stringify(DEFAULT_FRAGMENT) : '';
},
});
function makeFragmentField(key) {
return computed({
get: () => (fragment.value ? readJson('subJsonFragment', DEFAULT_FRAGMENT)[key] : ''),
set: (v) => {
if (v === '') return;
const f = readJson('subJsonFragment', { ...DEFAULT_FRAGMENT });
f[key] = v;
writeJson('subJsonFragment', f);
},
});
}
const fragmentPackets = makeFragmentField('packets');
const fragmentLength = makeFragmentField('length');
const fragmentInterval = makeFragmentField('interval');
const fragmentMaxSplit = makeFragmentField('maxSplit');
// === Noises =============================================================
const noises = computed({
get: () => props.allSetting.subJsonNoises !== '',
set: (v) => {
props.allSetting.subJsonNoises = v ? JSON.stringify(DEFAULT_NOISES) : '';
},
});
const noisesArray = computed({
get: () => (noises.value ? readJson('subJsonNoises', DEFAULT_NOISES) : []),
set: (value) => { if (noises.value) writeJson('subJsonNoises', value); },
});
function addNoise() {
noisesArray.value = [...noisesArray.value, { ...DEFAULT_NOISES[0] }];
}
function removeNoise(index) {
const next = [...noisesArray.value];
next.splice(index, 1);
noisesArray.value = next;
}
function updateNoiseField(index, field, value) {
const next = [...noisesArray.value];
next[index] = { ...next[index], [field]: value };
noisesArray.value = next;
}
// === Mux ================================================================
const enableMux = computed({
get: () => props.allSetting.subJsonMux !== '',
set: (v) => {
props.allSetting.subJsonMux = v ? JSON.stringify(DEFAULT_MUX) : '';
},
});
function makeMuxField(key, fallback) {
return computed({
get: () => (enableMux.value ? readJson('subJsonMux', DEFAULT_MUX)[key] : fallback),
set: (v) => {
const m = readJson('subJsonMux', { ...DEFAULT_MUX });
m[key] = v;
writeJson('subJsonMux', m);
},
});
}
const muxConcurrency = makeMuxField('concurrency', -1);
const muxXudpConcurrency = makeMuxField('xudpConcurrency', -1);
const muxXudpProxyUDP443 = makeMuxField('xudpProxyUDP443', 'reject');
// === Direct routing rules ==============================================
// `subJsonRules` is a JSON array of xray routing rules. We surface the
// IP and domain fields of the two seed rules as multi-select tags.
const enableDirect = computed({
get: () => props.allSetting.subJsonRules !== '',
set: (v) => {
props.allSetting.subJsonRules = v ? JSON.stringify(DEFAULT_RULES) : '';
},
});
function ruleArray() {
if (!enableDirect.value) return null;
const rules = readJson('subJsonRules', null);
return Array.isArray(rules) ? rules : null;
}
const directIPs = computed({
get: () => {
const rules = ruleArray();
if (!rules) return [];
const ipRule = rules.find((r) => r.ip);
return ipRule?.ip ?? [];
},
set: (value) => {
let rules = ruleArray();
if (!rules) return;
if (value.length === 0) {
rules = rules.filter((r) => !r.ip);
} else {
let idx = rules.findIndex((r) => r.ip);
if (idx === -1) idx = rules.push({ ...DEFAULT_RULES[1] }) - 1;
rules[idx].ip = [...value];
}
writeJson('subJsonRules', rules);
},
});
const directDomains = computed({
get: () => {
const rules = ruleArray();
if (!rules) return [];
const dRule = rules.find((r) => r.domain);
return dRule?.domain ?? [];
},
set: (value) => {
let rules = ruleArray();
if (!rules) return;
if (value.length === 0) {
rules = rules.filter((r) => !r.domain);
} else {
let idx = rules.findIndex((r) => r.domain);
if (idx === -1) idx = rules.push({ ...DEFAULT_RULES[0] }) - 1;
rules[idx].domain = [...value];
}
writeJson('subJsonRules', rules);
},
});
</script>
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem v-if="allSetting.subJsonEnable" paddings="small">
<template #title>JSON {{ t('pages.settings.subPath') }}</template>
<template #description>{{ t('pages.settings.subPathDesc') }}</template>
<template #control>
<a-input v-model:value="subJsonPath" type="text" placeholder="/json/" @blur="normalizePath('subJsonPath')" />
</template>
</SettingListItem>
<SettingListItem v-if="allSetting.subJsonEnable" paddings="small">
<template #title>JSON {{ t('pages.settings.subURI') }}</template>
<template #description>{{ t('pages.settings.subURIDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subJsonURI" type="text" placeholder="(http|https)://domain[:port]/path/" />
</template>
</SettingListItem>
<SettingListItem v-if="allSetting.subClashEnable" paddings="small">
<template #title>Clash {{ t('pages.settings.subPath') }}</template>
<template #description>{{ t('pages.settings.subPathDesc') }}</template>
<template #control>
<a-input v-model:value="subClashPath" type="text" placeholder="/clash/"
@blur="normalizePath('subClashPath')" />
</template>
</SettingListItem>
<SettingListItem v-if="allSetting.subClashEnable" paddings="small">
<template #title>Clash {{ t('pages.settings.subURI') }}</template>
<template #description>{{ t('pages.settings.subURIDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subClashURI" type="text"
placeholder="(http|https)://domain[:port]/path/" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" :header="t('pages.settings.fragment')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.fragment') }}</template>
<template #description>{{ t('pages.settings.fragmentDesc') }}</template>
<template #control>
<a-switch v-model:checked="fragment" />
</template>
</SettingListItem>
<a-list-item v-if="fragment" class="nested-block">
<a-collapse>
<a-collapse-panel :header="t('pages.settings.fragmentSett')">
<SettingListItem paddings="small">
<template #title>Packets</template>
<template #control>
<a-input v-model:value="fragmentPackets" placeholder="1-1 | 1-3 | tlshello | …" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Length</template>
<template #control>
<a-input v-model:value="fragmentLength" placeholder="100-200" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Interval</template>
<template #control>
<a-input v-model:value="fragmentInterval" placeholder="10-20" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Max split</template>
<template #control>
<a-input v-model:value="fragmentMaxSplit" placeholder="300-400" />
</template>
</SettingListItem>
</a-collapse-panel>
</a-collapse>
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="3" header="Noises">
<SettingListItem paddings="small">
<template #title>Noises</template>
<template #description>{{ t('pages.settings.noisesDesc') }}</template>
<template #control>
<a-switch v-model:checked="noises" />
</template>
</SettingListItem>
<a-list-item v-if="noises" class="nested-block">
<a-collapse>
<a-collapse-panel v-for="(noise, index) in noisesArray" :key="index" :header="`Noise №${index + 1}`">
<SettingListItem paddings="small">
<template #title>Type</template>
<template #control>
<a-select :value="noise.type" :style="{ width: '100%' }"
@change="(v) => updateNoiseField(index, 'type', v)">
<a-select-option v-for="p in ['rand', 'base64', 'str', 'hex']" :key="p" :value="p">
{{ p }}
</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Packet</template>
<template #control>
<a-input :value="noise.packet" placeholder="5-10"
@input="(e) => updateNoiseField(index, 'packet', e.target.value)" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Delay (ms)</template>
<template #control>
<a-input :value="noise.delay" placeholder="10-20"
@input="(e) => updateNoiseField(index, 'delay', e.target.value)" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Apply to</template>
<template #control>
<a-select :value="noise.applyTo" :style="{ width: '100%' }"
@change="(v) => updateNoiseField(index, 'applyTo', v)">
<a-select-option v-for="p in ['ip', 'ipv4', 'ipv6']" :key="p" :value="p">
{{ p }}
</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-space direction="horizontal" :style="{ padding: '10px 20px' }">
<a-button v-if="noisesArray.length > 1" type="primary" danger @click="removeNoise(index)">
{{ t('delete') }}
</a-button>
</a-space>
</a-collapse-panel>
</a-collapse>
<a-button type="primary" :style="{ marginTop: '10px' }" @click="addNoise">+ Noise</a-button>
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="4" :header="t('pages.settings.mux')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.mux') }}</template>
<template #description>{{ t('pages.settings.muxDesc') }}</template>
<template #control>
<a-switch v-model:checked="enableMux" />
</template>
</SettingListItem>
<a-list-item v-if="enableMux" class="nested-block">
<a-collapse>
<a-collapse-panel :header="t('pages.settings.muxSett')">
<SettingListItem paddings="small">
<template #title>Concurrency</template>
<template #control>
<a-input-number v-model:value="muxConcurrency" :min="-1" :max="1024" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>xudp concurrency</template>
<template #control>
<a-input-number v-model:value="muxXudpConcurrency" :min="-1" :max="1024" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>xudp UDP 443</template>
<template #control>
<a-select v-model:value="muxXudpProxyUDP443" :style="{ width: '100%' }">
<a-select-option v-for="p in ['reject', 'allow', 'skip']" :key="p" :value="p">
{{ p }}
</a-select-option>
</a-select>
</template>
</SettingListItem>
</a-collapse-panel>
</a-collapse>
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="5" :header="t('pages.settings.direct')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.direct') }}</template>
<template #description>{{ t('pages.settings.directDesc') }}</template>
<template #control>
<a-switch v-model:checked="enableDirect" />
</template>
</SettingListItem>
<a-list-item v-if="enableDirect" class="nested-block">
<a-collapse>
<a-collapse-panel :header="t('pages.settings.direct')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.direct') }} IPs</template>
<template #control>
<a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in directIPsOptions" :key="p.value" :value="p.value" :label="p.label">
{{ p.label }}
</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.direct') }} {{ t('domainName') }}</template>
<template #control>
<a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in directDomainsOptions" :key="p.value" :value="p.value" :label="p.label">
{{ p.label }}
</a-select-option>
</a-select>
</template>
</SettingListItem>
</a-collapse-panel>
</a-collapse>
</a-list-item>
</a-collapse-panel>
</a-collapse>
</template>
<style scoped>
.nested-block {
padding: 10px 20px;
}
</style>

View file

@ -0,0 +1,140 @@
import { Collapse, Divider, Input, InputNumber, Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting';
import SettingListItem from '@/components/SettingListItem';
interface SubscriptionGeneralTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
function sanitizePath(input: string): string {
return String(input ?? '').replace(/[:*]/g, '');
}
function normalizePath(input: string): string {
let p = input || '/';
if (!p.startsWith('/')) p = '/' + p;
if (!p.endsWith('/')) p += '/';
p = p.replace(/\/+/g, '/');
return p;
}
export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
return (
<Collapse defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEnable')} description={t('pages.settings.subEnableDesc')}>
<Switch checked={allSetting.subEnable} onChange={(v) => updateSetting({ subEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="JSON subscription" description={t('pages.settings.subJsonEnable')}>
<Switch checked={allSetting.subJsonEnable} onChange={(v) => updateSetting({ subJsonEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Clash / Mihomo subscription">
<Switch checked={allSetting.subClashEnable} onChange={(v) => updateSetting({ subClashEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subListen')} description={t('pages.settings.subListenDesc')}>
<Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subPath}
placeholder="/sub/"
onChange={(e) => updateSetting({ subPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subURI')} description={t('pages.settings.subURIDesc')}>
<Input value={allSetting.subURI} placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subURI: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '2',
label: t('pages.settings.information'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEncrypt')} description={t('pages.settings.subEncryptDesc')}>
<Switch checked={allSetting.subEncrypt} onChange={(v) => updateSetting({ subEncrypt: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subShowInfo')} description={t('pages.settings.subShowInfoDesc')}>
<Switch checked={allSetting.subShowInfo} onChange={(v) => updateSetting({ subShowInfo: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subEmailInRemark')} description={t('pages.settings.subEmailInRemarkDesc')}>
<Switch checked={allSetting.subEmailInRemark} onChange={(v) => updateSetting({ subEmailInRemark: v })} />
</SettingListItem>
<Divider>{t('pages.settings.subTitle')}</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subSupportUrl')} description={t('pages.settings.subSupportUrlDesc')}>
<Input value={allSetting.subSupportUrl} placeholder="https://example.com"
onChange={(e) => updateSetting({ subSupportUrl: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subProfileUrl')} description={t('pages.settings.subProfileUrlDesc')}>
<Input value={allSetting.subProfileUrl} placeholder="https://example.com"
onChange={(e) => updateSetting({ subProfileUrl: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subAnnounce')} description={t('pages.settings.subAnnounceDesc')}>
<Input.TextArea value={allSetting.subAnnounce}
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
</SettingListItem>
<Divider>Happ</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
<Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} description={t('pages.settings.subRoutingRulesDesc')}>
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '3',
label: t('pages.settings.certs'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
<Input value={allSetting.subCertFile} onChange={(e) => updateSetting({ subCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subKeyPath')} description={t('pages.settings.subKeyPathDesc')}>
<Input value={allSetting.subKeyFile} onChange={(e) => updateSetting({ subKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '4',
label: t('pages.settings.intervals'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
<InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
]} />
);
}

View file

@ -1,204 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
const props = defineProps({
allSetting: { type: Object, required: true },
});
// Sub path is constrained: no `:` or `*`, must start and end with `/`,
// and no double slashes. Strip on input, normalize on blur same
// behavior as the legacy template.
const subPath = computed({
get: () => props.allSetting.subPath,
set: (v) => {
props.allSetting.subPath = String(v ?? '').replace(/[:*]/g, '');
},
});
function normalizeSubPath() {
let p = props.allSetting.subPath || '/';
if (!p.startsWith('/')) p = '/' + p;
if (!p.endsWith('/')) p += '/';
p = p.replace(/\/+/g, '/');
props.allSetting.subPath = p;
}
</script>
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subEnable') }}</template>
<template #description>{{ t('pages.settings.subEnableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>JSON subscription</template>
<template #description>{{ t('pages.settings.subJsonEnable') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subJsonEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Clash / Mihomo subscription</template>
<template #control>
<a-switch v-model:checked="allSetting.subClashEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subListen') }}</template>
<template #description>{{ t('pages.settings.subListenDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subListen" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subDomain') }}</template>
<template #description>{{ t('pages.settings.subDomainDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subDomain" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subPort') }}</template>
<template #description>{{ t('pages.settings.subPortDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.subPort" :min="1" :max="65535" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subPath') }}</template>
<template #description>{{ t('pages.settings.subPathDesc') }}</template>
<template #control>
<a-input v-model:value="subPath" type="text" placeholder="/sub/" @blur="normalizeSubPath" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subURI') }}</template>
<template #description>{{ t('pages.settings.subURIDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subURI" type="text" placeholder="(http|https)://domain[:port]/path/" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" :header="t('pages.settings.information')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subEncrypt') }}</template>
<template #description>{{ t('pages.settings.subEncryptDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEncrypt" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subShowInfo') }}</template>
<template #description>{{ t('pages.settings.subShowInfoDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subShowInfo" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subEmailInRemark') }}</template>
<template #description>{{ t('pages.settings.subEmailInRemarkDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEmailInRemark" />
</template>
</SettingListItem>
<a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subTitle') }}</template>
<template #description>{{ t('pages.settings.subTitleDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subTitle" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subSupportUrl') }}</template>
<template #description>{{ t('pages.settings.subSupportUrlDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subSupportUrl" type="text" placeholder="https://example.com" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subProfileUrl') }}</template>
<template #description>{{ t('pages.settings.subProfileUrlDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subProfileUrl" type="text" placeholder="https://example.com" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subAnnounce') }}</template>
<template #description>{{ t('pages.settings.subAnnounceDesc') }}</template>
<template #control>
<a-textarea v-model:value="allSetting.subAnnounce" />
</template>
</SettingListItem>
<a-divider>Happ</a-divider>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subEnableRouting') }}</template>
<template #description>{{ t('pages.settings.subEnableRoutingDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEnableRouting" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subRoutingRules') }}</template>
<template #description>{{ t('pages.settings.subRoutingRulesDesc') }}</template>
<template #control>
<a-textarea v-model:value="allSetting.subRoutingRules" placeholder="happ://routing/add/..." />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.settings.certs')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subCertPath') }}</template>
<template #description>{{ t('pages.settings.subCertPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subCertFile" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subKeyPath') }}</template>
<template #description>{{ t('pages.settings.subKeyPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subKeyFile" type="text" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" :header="t('pages.settings.intervals')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subUpdates') }}</template>
<template #description>{{ t('pages.settings.subUpdatesDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.subUpdates" :min="1" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>
</a-collapse>
</template>

View file

@ -0,0 +1,106 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Input, InputNumber, Select, Switch } from 'antd';
import { LanguageManager } from '@/utils';
import type { AllSetting } from '@/models/setting';
import SettingListItem from '@/components/SettingListItem';
interface TelegramTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation();
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
})),
[],
);
return (
<Collapse defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotEnable')} description={t('pages.settings.telegramBotEnableDesc')}>
<Switch checked={allSetting.tgBotEnable} onChange={(v) => updateSetting({ tgBotEnable: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramToken')}
description={allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc')}
>
<Input.Password
value={allSetting.tgBotToken}
placeholder={allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''}
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramChatId')} description={t('pages.settings.telegramChatIdDesc')}>
<Input value={allSetting.tgBotChatId} onChange={(e) => updateSetting({ tgBotChatId: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotLanguage')}>
<Select
value={allSetting.tgLang}
onChange={(v) => updateSetting({ tgLang: v })}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: t('pages.settings.notifications'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
<Input value={allSetting.tgRunTime} onChange={(e) => updateSetting({ tgRunTime: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyLogin')} description={t('pages.settings.tgNotifyLoginDesc')}>
<Switch checked={allSetting.tgBotLoginNotify} onChange={(v) => updateSetting({ tgBotLoginNotify: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyCpu')} description={t('pages.settings.tgNotifyCpuDesc')}>
<InputNumber value={allSetting.tgCpu} min={0} max={100} style={{ width: '100%' }}
onChange={(v) => updateSetting({ tgCpu: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
{
key: '3',
label: t('pages.settings.proxyAndServer'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramProxy')} description={t('pages.settings.telegramProxyDesc')}>
<Input value={allSetting.tgBotProxy} placeholder="socks5://user:pass@host:port"
onChange={(e) => updateSetting({ tgBotProxy: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
<Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
</SettingListItem>
</>
),
},
]} />
);
}

View file

@ -1,109 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
defineProps({
allSetting: { type: Object, required: true },
});
</script>
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramBotEnable') }}</template>
<template #description>{{ t('pages.settings.telegramBotEnableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.tgBotEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramToken') }}</template>
<template #description>
{{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }}
</template>
<template #control>
<a-input-password v-model:value="allSetting.tgBotToken"
:placeholder="allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramChatId') }}</template>
<template #description>{{ t('pages.settings.telegramChatIdDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgBotChatId" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramBotLanguage') }}</template>
<template #control>
<a-select v-model:value="allSetting.tgLang" :style="{ width: '100%' }">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
:label="l.value">
<span role="img" :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" :header="t('pages.settings.notifications')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramNotifyTime') }}</template>
<template #description>{{ t('pages.settings.telegramNotifyTimeDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgRunTime" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.tgNotifyBackup') }}</template>
<template #description>{{ t('pages.settings.tgNotifyBackupDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.tgBotBackup" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.tgNotifyLogin') }}</template>
<template #description>{{ t('pages.settings.tgNotifyLoginDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.tgBotLoginNotify" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.tgNotifyCpu') }}</template>
<template #description>{{ t('pages.settings.tgNotifyCpuDesc') }}</template>
<template #control>
<a-input-number v-model:value="allSetting.tgCpu" :min="0" :max="100" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.settings.proxyAndServer')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramProxy') }}</template>
<template #description>{{ t('pages.settings.telegramProxyDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgBotProxy" type="text" placeholder="socks5://user:pass@host:port" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramAPIServer') }}</template>
<template #description>{{ t('pages.settings.telegramAPIServerDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgBotAPIServer" type="text" placeholder="https://api.example.com" />
</template>
</SettingListItem>
</a-collapse-panel>
</a-collapse>
</template>

View file

@ -0,0 +1,20 @@
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qr-code {
cursor: pointer;
padding: 0 !important;
background: #fff;
border-radius: 6px;
}
.qr-token {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
text-align: center;
}

View file

@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
import './TwoFactorModal.css';
type Type = 'set' | 'confirm';
interface TwoFactorModalProps {
open: boolean;
title?: string;
description?: string;
token?: string;
type?: Type;
onConfirm: (success: boolean, code?: string) => void;
onOpenChange: (open: boolean) => void;
}
export default function TwoFactorModal({
open,
title = '',
description = '',
token = '',
type = 'set',
onConfirm,
onOpenChange,
}: TwoFactorModalProps) {
const { t } = useTranslation();
const [enteredCode, setEnteredCode] = useState('');
const [qrValue, setQrValue] = useState('');
const totpRef = useRef<OTPAuth.TOTP | null>(null);
useEffect(() => {
if (!open) return;
/* eslint-disable react-hooks/set-state-in-effect */
setEnteredCode('');
totpRef.current = null;
setQrValue('');
if (token) {
const totp = new OTPAuth.TOTP({
issuer: '3x-ui',
label: 'Administrator',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: token,
});
totpRef.current = totp;
setQrValue(totp.toString());
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [open, token]);
function close(success: boolean, code = '') {
onConfirm(success, code);
onOpenChange(false);
setEnteredCode('');
}
function onOk() {
if (type === 'confirm' && !token) {
close(true, enteredCode);
return;
}
if (!totpRef.current) return;
if (totpRef.current.generate() === enteredCode) {
close(true);
} else {
message.error(t('pages.settings.security.twoFactorModalError'));
}
}
function onCancel() {
close(false);
}
async function copyToken() {
const ok = await ClipboardManager.copyText(token);
if (ok) message.success(t('copied'));
}
return (
<Modal
open={open}
title={title}
closable
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
<Button key="ok" type="primary" disabled={enteredCode.length < 6} onClick={onOk}>
{t('confirm')}
</Button>,
]}
>
{type === 'set' ? (
<>
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div className="qr-wrap">
<QRCode
className="qr-code"
value={qrValue}
size={180}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
onClick={copyToken}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
</>
) : (
<>
<p>{description}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
</>
)}
</Modal>
);
}

View file

@ -1,126 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
description: { type: String, default: '' },
token: { type: String, default: '' },
type: { type: String, default: 'set', validator: (v) => ['set', 'confirm'].includes(v) },
});
const emit = defineEmits(['update:open', 'confirm']);
const enteredCode = ref('');
const qrValue = ref('');
let totp = null;
function buildTotp() {
totp = new OTPAuth.TOTP({
issuer: '3x-ui',
label: 'Administrator',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: props.token,
});
qrValue.value = totp.toString();
}
watch(() => props.open, (next) => {
if (!next) return;
enteredCode.value = '';
totp = null;
qrValue.value = '';
if (props.token) {
buildTotp();
}
});
function close(success, code = '') {
emit('confirm', success, code);
emit('update:open', false);
enteredCode.value = '';
}
function onOk() {
if (props.type === 'confirm' && !props.token) {
close(true, enteredCode.value);
return;
}
if (!totp) return;
if (totp.generate() === enteredCode.value) {
close(true);
} else {
message.error(t('pages.settings.security.twoFactorModalError'));
}
}
function onCancel() {
close(false);
}
async function copyToken() {
const ok = await ClipboardManager.copyText(props.token);
if (ok) message.success(t('copied'));
}
</script>
<template>
<a-modal :open="open" :title="title" :closable="true" @cancel="onCancel">
<template v-if="type === 'set'">
<p>{{ t('pages.settings.security.twoFactorModalSteps') }}</p>
<a-divider />
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
<div class="qr-wrap">
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
<span class="qr-token">{{ token }}</span>
</div>
<a-divider />
<p>{{ t('pages.settings.security.twoFactorModalSecondStep') }}</p>
<a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
</template>
<template v-else>
<p>{{ description }}</p>
<a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
</template>
<template #footer>
<a-button @click="onCancel">{{ t('cancel') }}</a-button>
<a-button type="primary" :disabled="enteredCode.length < 6" @click="onOk">{{ t('confirm') }}</a-button>
</template>
</a-modal>
</template>
<style scoped>
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qr-code {
cursor: pointer;
padding: 0 !important;
background: #fff;
border-radius: 6px;
}
.qr-token {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
text-align: center;
}
</style>

View file

@ -1,80 +0,0 @@
// Centralizes the AllSetting fetch/save lifecycle the legacy panel
// scattered across data() + methods + a busy-loop dirty checker.
//
// The dirty flag is recomputed once per second (matching the legacy
// `while (true) sleep(1000)` poll) — we don't deep-watch because the
// settings tree has many nested fields and a poll is cheap enough.
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { HttpUtil } from '@/utils';
import { AllSetting } from '@/models/setting.js';
const DIRTY_POLL_MS = 1000;
export function useAllSetting() {
const fetched = ref(false);
const spinning = ref(false);
const saveDisabled = ref(true);
// Two reactive snapshots: the last server-side state and the one the
// user is editing. `equals` compares enumerable props field-by-field.
const oldAllSetting = reactive(new AllSetting());
const allSetting = reactive(new AllSetting());
function applyServerState(obj) {
const fresh = new AllSetting(obj);
Object.assign(oldAllSetting, fresh);
Object.assign(allSetting, fresh);
saveDisabled.value = true;
}
async function fetchAll() {
const msg = await HttpUtil.post('/panel/setting/all');
if (msg?.success) {
fetched.value = true;
applyServerState(msg.obj);
}
}
async function saveAll() {
spinning.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/update', allSetting);
if (msg?.success) await fetchAll();
} finally {
spinning.value = false;
}
}
let timer = null;
function startDirtyPoll() {
if (timer != null) return;
timer = setInterval(() => {
// ObjectUtil.equals walks own enumerable props; reactive proxies
// expose them transparently so this works without cloning.
saveDisabled.value = oldAllSetting.equals(allSetting);
}, DIRTY_POLL_MS);
}
function stopDirtyPoll() {
if (timer != null) {
clearInterval(timer);
timer = null;
}
}
onMounted(() => {
fetchAll();
startDirtyPoll();
});
onUnmounted(stopDirtyPoll);
return {
fetched,
spinning,
saveDisabled,
oldAllSetting,
allSetting,
fetchAll,
saveAll,
};
}