mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 01:47:15 +00:00
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* 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.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,90 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.security-actions {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { ClipboardManager, 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 [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
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(() => {
|
||||
|
||||
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;
|
||||
const ok = await ClipboardManager.copyText(token);
|
||||
if (ok) messageApi.success(t('copySuccess'));
|
||||
else messageApi.error(t('copyFail') ?? 'Copy failed');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
setCreateName('');
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
async function confirmCreateToken() {
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
messageApi.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) {
|
||||
messageApi.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;
|
||||
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
||||
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
{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>
|
||||
<div className="security-actions">
|
||||
<Space style={{ padding: '0 20px' }}>
|
||||
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
@@ -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: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.settings-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
FloatButton,
|
||||
Layout,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
CodeOutlined,
|
||||
MessageOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
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 '@/styles/page-cards.css';
|
||||
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 [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
|
||||
const {
|
||||
allSetting,
|
||||
updateSetting,
|
||||
fetched,
|
||||
spinning,
|
||||
setSpinning,
|
||||
saveDisabled,
|
||||
saveAll,
|
||||
} = useAllSetting();
|
||||
|
||||
const [entryHost, setEntryHost] = useState('');
|
||||
const [entryPort, setEntryPort] = useState('');
|
||||
const [entryIsIP, setEntryIsIP] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const host = window.location.hostname;
|
||||
setEntryHost(host);
|
||||
setEntryPort(window.location.port);
|
||||
setEntryIsIP(isIp(host));
|
||||
|
||||
}, []);
|
||||
|
||||
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}>
|
||||
{messageContextHolder}
|
||||
{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} description="Loading…" size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : (
|
||||
<>
|
||||
{confAlerts.length > 0 && alertVisible && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
className="conf-alert"
|
||||
onClose={() => setAlertVisible(false)}
|
||||
title="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>
|
||||
<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">
|
||||
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
|
||||
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Card hoverable>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={onTabChange}
|
||||
className={isMobile ? 'icons-only' : ''}
|
||||
items={tabItems}
|
||||
/>
|
||||
</Card>
|
||||
</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>
|
||||
@@ -0,0 +1,4 @@
|
||||
.nested-block {
|
||||
padding: 10px 20px;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Input,
|
||||
InputNumber,
|
||||
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 && (
|
||||
<div 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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: 'Noises',
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title="Noises" description={t('pages.settings.noisesDesc')}>
|
||||
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
|
||||
</SettingListItem>
|
||||
{noisesEnabled && (
|
||||
<div 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 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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 && (
|
||||
<div 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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 && (
|
||||
<div 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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [enteredCode, setEnteredCode] = useState('');
|
||||
const [qrValue, setQrValue] = useState('');
|
||||
const totpRef = useRef<OTPAuth.TOTP | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
}, [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 {
|
||||
messageApi.error(t('pages.settings.security.twoFactorModalError'));
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
close(false);
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const ok = await ClipboardManager.copyText(token);
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user