mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
22e88ec4eb
commit
d50ec74b24
26 changed files with 2300 additions and 2300 deletions
|
|
@ -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>
|
||||
|
|
|
|||
30
frontend/src/components/SettingListItem.tsx
Normal file
30
frontend/src/components/SettingListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
28
frontend/src/entries/settings.tsx
Normal file
28
frontend/src/entries/settings.tsx
Normal 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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
69
frontend/src/hooks/useAllSetting.ts
Normal file
69
frontend/src/hooks/useAllSetting.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
100
frontend/src/models/setting.ts
Normal file
100
frontend/src/models/setting.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
352
frontend/src/pages/settings/GeneralTab.tsx
Normal file
352
frontend/src/pages/settings/GeneralTab.tsx
Normal 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>
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
<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>
|
||||
84
frontend/src/pages/settings/SecurityTab.css
Normal file
84
frontend/src/pages/settings/SecurityTab.css
Normal 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;
|
||||
}
|
||||
385
frontend/src/pages/settings/SecurityTab.tsx
Normal file
385
frontend/src/pages/settings/SecurityTab.tsx
Normal 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 }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
87
frontend/src/pages/settings/SettingsPage.css
Normal file
87
frontend/src/pages/settings/SettingsPage.css
Normal 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;
|
||||
}
|
||||
331
frontend/src/pages/settings/SettingsPage.tsx
Normal file
331
frontend/src/pages/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
4
frontend/src/pages/settings/SubscriptionFormatsTab.css
Normal file
4
frontend/src/pages/settings/SubscriptionFormatsTab.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.nested-block {
|
||||
padding: 10px 20px;
|
||||
display: block !important;
|
||||
}
|
||||
434
frontend/src/pages/settings/SubscriptionFormatsTab.tsx
Normal file
434
frontend/src/pages/settings/SubscriptionFormatsTab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
140
frontend/src/pages/settings/SubscriptionGeneralTab.tsx
Normal file
140
frontend/src/pages/settings/SubscriptionGeneralTab.tsx
Normal 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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
106
frontend/src/pages/settings/TelegramTab.tsx
Normal file
106
frontend/src/pages/settings/TelegramTab.tsx
Normal 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>
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
<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>
|
||||
20
frontend/src/pages/settings/TwoFactorModal.css
Normal file
20
frontend/src/pages/settings/TwoFactorModal.css
Normal 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;
|
||||
}
|
||||
129
frontend/src/pages/settings/TwoFactorModal.tsx
Normal file
129
frontend/src/pages/settings/TwoFactorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue