mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-11 22:00:59 +00:00
Migrate frontend models/api/utils to TypeScript and modernize AntD theming (#4563)
* refactor(frontend): port api/* and reality-targets to TypeScript
Phase 1 of the JS→TS migration: convert three small, isolated files
(axios-init, websocket, reality-targets) to typed sources so future
phases can lean on their interfaces.
- api/axios-init.ts: typed CSRF cache, interceptors, request retry
- api/websocket.ts: typed listener map, message envelope guard,
reconnect timer
- models/reality-targets.ts: RealityTarget interface, readonly list
- env.d.ts: minimal qs module shim (stringify/parse)
- consumers: drop ".js" extension from @/api imports
* refactor(frontend): port utils/index to TypeScript
Phase 2 of the JS→TS migration: convert the 858-line utility module
that 30+ pages and hooks depend on.
- Msg<T = any> generic with success/msg/obj shape preserved
- HttpUtil get/post/postWithModal generic over response shape
- RandomUtil, Wireguard, Base64 fully typed
- SizeFormatter/CPUFormatter/TimeFormatter/NumberFormatter typed
- ColorUtils.usageColor returns 'green'|'orange'|'red'|'purple' union
- LanguageManager.supportedLanguages readonly typed
- IntlUtil.formatDate/formatRelativeTime accept null/undefined
- ObjectUtil.clone/deepClone/cloneProps/equals kept as `any`-shaped
to preserve the prior JS contract used by class-instance callers
(AllSetting.cloneProps(this, data), etc.)
* refactor(frontend): port models/outbound to TypeScript (hybrid typing)
Phase 4 of the JS→TS migration: rename outbound.js to outbound.ts and
make it compile under strict mode with a minimal hybrid type pass.
- Enum-like constants kept as typed objects (Protocols, SSMethods, …)
- Top-level DNS helpers strictly typed
- CommonClass gets [key: string]: any so all subclasses can keep their
loose this.foo = bar assignments without per-field declarations
- Constructor / fromJson / toJson signatures typed as any to preserve
the prior JS contract used by consumers and parsers
- Outbound declares static fields for the dynamically-attached Settings
subclasses (Settings, FreedomSettings, VmessSettings, …)
- urlParams.get() results that feed parseInt now use the non-null
assertion since the surrounding has() check already guards them
- File-level eslint-disable for no-explicit-any/no-var/prefer-const to
keep the JS-derived code building without churn
* refactor(frontend): port models/inbound to TypeScript (hybrid typing)
Phase 5 of the JS→TS migration. Same hybrid approach as outbound.ts:
constants typed strictly, classes get [key: string]: any from
XrayCommonClass, constructor / fromJson / toJson signatures use any.
- XrayCommonClass gains [key: string]: any plus typed static helpers
(toJsonArray, fallbackToJson, toHeaders, toV2Headers)
- TcpStreamSettings/TlsStreamSettings/RealityStreamSettings/Inbound
declare static fields for their dynamically-attached subclasses
(TcpRequest, TcpResponse, Cert, Settings, ClientBase, Vmess/VLESS/
Trojan/Shadowsocks/Hysteria/Tunnel/Mixed/Http/Wireguard/TunSettings)
- All gen*Link, applyXhttpExtra*, applyExternalProxyTLS*, applyFinalMask*
and related helpers explicitly any-typed
- Constructor positional client-args (email, limitIp, totalGB, …) typed
as optional any across Vmess/VLESS/Trojan/Shadowsocks/Hysteria.VMESS|
VLESS|Trojan|Shadowsocks|Hysteria
- File-level eslint-disable for no-explicit-any/prefer-const/
no-case-declarations/no-array-constructor to silence churn without
changing behavior
* refactor(frontend): port models/dbinbound to TypeScript
Phase 6 — final phase of the JS→TS migration. Frontend src/ no
longer contains any *.js files.
- DBInbound declares all fields explicitly (id, userId, up, down,
total, …, nodeId, fallbackParent) with proper types
- _expiryTime getter/setter typed against dayjs.Dayjs
- coerceInboundJsonField takes unknown, returns any
- Private cache fields (_cachedInbound, _clientStatsMap) declared
- Consumers (InboundFormModal, InboundsPage, useInbounds): drop ".js"
extension from @/models/dbinbound imports
* refactor(frontend): drop .js extensions from TS-resolved imports
Cleanup after the JS→TS migration:
- All consumers that imported @/models/{inbound,outbound,dbinbound}.js
now drop the .js extension (TS module resolution lands on the .ts
file automatically)
- eslint.config.js: remove the **/*.js block since the only remaining
JS file under src/ is endpoints.js (build-script consumed only) and
js.configs.recommended already covers it correctly
* refactor(frontend): tighten inbound.ts cleanup wins
Checkpoint before the full any → typed pass:
- Wrap 15 case bodies in braces (no-case-declarations)
- Convert 14 let → const in genLink helpers (prefer-const)
- new Array() → [] for shadowsocks passwords (no-array-constructor)
- XrayCommonClass: HeaderEntry, FallbackEntry, JsonObject interfaces;
fromJson/toV2Headers/toHeaders typed against them; static methods
return JsonObject / HeaderEntry[] instead of any
- Reduce file-level eslint-disable scope from 4 rules to just
no-explicit-any (the only one still needed)
* refactor(frontend): drop eslint-disable from models/dbinbound
Replace `any` with explicit domain types:
- `coerceInboundJsonField` returns `Record<string, unknown>` (settings/streamSettings/sniffing are always objects).
- Add `RawJsonField`, `ClientStats`, `FallbackParentRef`, `DBInboundInit` types.
- `_cachedInbound: Inbound | null`, `toInbound(): Inbound`.
- `getClientStats(email): ClientStats | undefined`.
- `genInboundLinks(): string` (matches actual return from Inbound.genInboundLinks).
- Constructor now accepts `DBInboundInit`.
* refactor(frontend): drop eslint-disable from InboundsPage
Type all callbacks against DBInbound from @/models/dbinbound:
- state setters use DBInbound | null
- helpers (projectChildThroughMaster, checkFallback, findClientIndex,
exportInboundLinks, etc.) take DBInbound
- drop `(dbInbounds as any[])` casts; useInbounds already returns DBInbound[]
- introduce ClientMatchTarget for findClientIndex's `client` param
- tighten DBInbound.clientStats to ClientStats[] (default [])
- single boundary cast at <InboundList onRowAction=> to bridge
InboundList's narrower DBInboundRecord (cleanup belongs with InboundList)
* refactor(frontend): drop file-level eslint-disable from utils/index
- ObjectUtil.clone/deepClone become generic <T>
- cloneProps/delProps accept `object` (cast internally to AnyRecord)
- equals accepts `unknown` with proper narrowing
- ColorUtils.usageColor narrows data/threshold to `number`; total widened
to `number | { valueOf(): number } | null | undefined` so Dayjs works
- Utils.debounce replaces `const self = this` with lexical arrow
closure (no-this-alias clean)
- InboundList._expiryTime narrowed from `unknown` to `{ valueOf(): number } | null`
- Single-line eslint-disable remains on `Msg<T = any>` and HttpUtil
generic defaults (idiomatic API envelope; changing default to unknown
cascades through 34 consumer files)
* refactor(frontend): drop eslint-disable from OutboundFormModal field section
Replace `type OB = any` with `type OB = Outbound`. Body code still
sees protocol fields as `any` via Outbound's inherited [key: string]: any
index signature (CommonClass) — that escape hatch will narrow as
Phase 6 tightens outbound.ts itself.
The intentional `// eslint-disable-next-line` on `useRef<any>(null)`
at line 72 stays — out of scope per plan.
* refactor(frontend): drop file-level eslint-disable from InboundFormModal
Add minimal local interfaces for protocol-specific shapes the form reads:
- StreamLike, TlsCert, VlessClient, ShadowsocksClient, HttpAccount,
WireguardPeer (replace with real exports from inbound.ts as Phase 7
exports them).
- Props typed as DBInbound | null + DBInbound[].
- Drop unnecessary `(Inbound as any).X`, `(RandomUtil as any).X`,
`(Wireguard as any).X`, `(DBInbound as any)(...)` casts — they are
already typed classes; only `Inbound.Settings`/`Inbound.HttpSettings`
remain `any` via static field on Inbound (will tighten in Phase 7).
- inboundRef/dbFormRef retain single-line `// eslint-disable-next-line`
for `useRef<any>(null)` — nullable narrowing across ~30 callsites
exceeds Phase 5 scope.
- payload locals typed Record<string, unknown>; setAdvancedAllValue
parses JSON into a narrowed object instead of `let parsed: any`.
* refactor(frontend): narrow outbound.ts eslint-disable to no-explicit-any only
- Fix all 36 prefer-const violations: convert never-reassigned `let` to
`const`; for mixed-mutability destructuring (fromParamLink,
fromHysteriaLink) split into separate `const`/`let` declarations
by index instead of destructuring.
- Fix both no-var violations: `var stream` / `var settings` → `let`.
- File still carries `/* eslint-disable @typescript-eslint/no-explicit-any */`
because tightening 223 `any` uses requires removing CommonClass's
`[key: string]: any` escape hatch and reshaping ~30 dynamically-attached
subclass patterns into named classes — multi-hour architectural work
tracked as Phase 7's twin for outbound.
* refactor(frontend): align sub page chrome with login + AntD defaults
- Theme + language buttons now both use AntD `<Button shape="circle"
size="large" className="toolbar-btn">` with TranslationOutlined and
the SVG theme icon — identical hover/border behaviour.
- Language popover content switched from hand-rolled `<ul.lang-list>`
to AntD `<Menu mode="vertical" selectable />`; gains native
hover/keyboard nav + active highlight.
- Drop `.info-table` `!important` border overrides (8 selectors) so
Descriptions inherits the AntD theme border colour.
- Drop `.qr-code` padding/background/border-radius overrides; only
`cursor: pointer` remains (QRCode handles padding/bg itself).
- Remove now-unused `.theme-cycle`, `.lang-list`, `.lang-item*`,
`.lang-select`, `.settings-popover` rules.
* refactor(frontend): drop CustomStatistic wrapper, move overrides to theme tokens
- Delete `<CustomStatistic>` (a pass-through wrapper over <Statistic>)
and its unscoped global `.ant-statistic-*` CSS overrides; consumers
(IndexPage, ClientsPage, InboundsPage, NodesPage) now import AntD
`<Statistic>` directly.
- Add Statistic component tokens to ConfigProvider so the title (11px)
and content (17px) font sizes still apply, without `!important`
global selectors.
- Move dark / ultra-dark card border colours from `body.dark .ant-card`
+ `html[data-theme='ultra-dark'] .ant-card` selectors into Card
`colorBorderSecondary` tokens; page-cards.css now only carries the
custom radius/shadow/transition that has no token equivalent.
- Simplify XrayStatusCard badge: remove the custom `xray-pulse` dot
keyframe and per-state ring-colour overrides; AntD `<Badge
status="processing" color={…}>` already pulses the ring in the same
colour, no extra CSS needed.
* refactor(frontend): modernize login page with AntD primitives
- Theme cycle button switched from `<button.theme-cycle>` + custom CSS
to AntD `<Button shape="circle" className="toolbar-btn">` (matches
sub page chrome already established).
- Theme icons switched from hand-rolled inline SVG (sun, moon,
moon+star) to AntD `<SunOutlined />`, `<MoonOutlined />`,
`<MoonFilled />` for the three light / dark / ultra-dark states.
- Language popover content switched from `<ul.lang-list>` +
`<button.lang-item>` to AntD `<Menu mode="vertical" selectable />`
with `selectedKeys=[lang]`; native hover / keyboard nav / active
highlight come for free.
- Drop CSS for `.theme-cycle`, `.lang-list`, `.lang-item*` (now unused).
`.toolbar-btn` retained since it sizes both circular buttons.
* refactor(frontend): switch sub page theme icons to AntD primitives
Replace the three hand-rolled SVG theme icons (sun, moon, moon+star)
with AntD `<SunOutlined />`, `<MoonOutlined />`, `<MoonFilled />`
for the light / dark / ultra-dark states. Switch the theme `<Button>`
to use the `icon` prop instead of children so it renders the same
way as the language button. Drop `.toolbar-btn svg` CSS — no longer
needed once the icon comes from AntD.
* refactor(frontend): drop !important overrides from pages CSS (Clients + Log modals + Settings tabs)
- ClientsPage: pagination size-changer `min-width !important` removed;
the 3-level selector specificity already beats AntD's defaults.
Scope `body.dark .client-card` to `.clients-page.is-dark .client-card`
(avoid leaking into other pages).
- LogModal + XrayLogModal: move the mobile full-screen tweaks
(`top: 0`, `padding-bottom: 0`, `max-width: 100vw`) from `!important`
class rules to the Modal's `style` prop; keep `.ant-modal-content`
/ `.ant-modal-body` overrides as plain CSS via the className.
- SubscriptionFormatsTab: drop `display: block !important` on
`.nested-block` — div is already block by default.
- TwoFactorModal: drop `padding/background/border-radius !important`
on `.qr-code`; AntD QRCode handles those itself.
* refactor(frontend): scope dark overrides and switch list borders to AntD CSS variables
Scope page-level dark overrides:
- inbounds/InboundList: scope `.ant-table` border-radius rules and the
mobile @media `.ant-card-*` tweaks to `.inbounds-page` (were global
and leaked into other pages); scope `.inbound-card` dark variant to
`.inbounds-page.is-dark`.
- nodes/NodeList: scope `.node-card` dark to `.nodes-page.is-dark`.
- xray/RoutingTab, OutboundsTab: scope `.rule-card`, `.criterion-chip`,
`.criterion-more`, `.address-pill` dark to `.xray-page.is-dark`.
Modernize list borders to use AntD CSS vars instead of body.dark forks:
- index/BackupModal, PanelUpdateModal, VersionModal: replace
hard-coded `rgba(5,5,5,0.06)` + `body.dark`/`html[data-theme]`
override pairs with `var(--ant-color-border-secondary)`; replace
custom text colours with `var(--ant-color-text)` /
`var(--ant-color-text-tertiary)`.
- xray/DnsPresetsModal: same border-color treatment.
- xray/NordModal, WarpModal: collapse `.row-odd` light + `body.dark`
pair into a single neutral `rgba(128,128,128,0.06)` that works on
both themes; scope under `.nord-data-table` / `.warp-data-table`.
* refactor(frontend): switch shared components CSS to AntD CSS variables
Replace body.dark / html[data-theme] forks with AntD CSS variables
in shared components (work in both light and dark, scale to ultra):
- SettingListItem: borders + text colours via
`--ant-color-border-secondary`, `--ant-color-text`,
`--ant-color-text-tertiary`.
- InputAddon: bg/border/text via `--ant-color-fill-tertiary`,
`--ant-color-border`, `--ant-color-text`.
- JsonEditor: host border/bg via `--ant-color-border`,
`--ant-color-bg-container`; focus border via `--ant-color-primary`.
- Sparkline (SVG): grid/text colours via `--ant-color-text*`
and `--ant-color-border-secondary`; only the tooltip drop-shadow
retains a body.dark fork (filter opacity needs explicit value).
* refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart
Replace the 368-line hand-rolled SVG sparkline (with manual
ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip,
custom Y-axis label thinning) with a thin Recharts `<AreaChart>`
wrapper that keeps the same prop API.
- Preserved props: data, labels, height, stroke, strokeWidth,
maxPoints, showGrid, fillOpacity, showMarker, markerRadius,
showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax,
yFormatter, tooltipFormatter.
- Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` —
Recharts' ResponsiveContainer handles width, and margins are wired
to whether axes are visible. Removed the unused `vbWidth` prop from
SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites.
- Tooltip, grid, and axis text now use AntD CSS variables for
automatic light/dark adaptation; replaced the SVG body.dark forks
in Sparkline.css with a single 5-line stylesheet.
- Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off
for less custom chart code to maintain and a more standard API
for future charts (multi-series, brush, etc.).
* build(frontend): split Recharts + d3 deps into vendor-recharts chunk
Pulls Recharts (~75KB gzip) and its d3-shape/array/color/path/scale
+ victory-vendor deps out of the catch-all vendor chunk so they
load on demand on the three pages that use Sparkline
(SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) and cache
independently from the rest of the panel JS.
* refactor(frontend): drop body.dark forks in favor of AntD CSS variables
- ClientInfoModal/InboundInfoModal: link-panel-text and link-panel-anchor now use
var(--ant-color-fill-tertiary) and color-mix on --ant-color-primary, removing
the body.dark light/dark background pair.
- InboundFormModal: advanced-panel uses --ant-color-border-secondary and
--ant-color-fill-quaternary; body.dark/html[data-theme='ultra-dark'] pair gone.
- CustomGeoSection: custom-geo-count, custom-geo-ext-code, custom-geo-copyable:hover
use --ant-color-fill-tertiary/-secondary; body.dark forks gone.
- SystemHistoryModal: cpu-chart-wrap collapsed from three theme-specific gradients
into one using color-mix on --ant-color-primary and --ant-color-fill-quaternary.
- page-cards.css: body.dark / html[data-theme='ultra-dark'] selectors renamed to
page-scoped .is-dark / .is-dark.is-ultra, keeping the same shadow tuning but
consistent with the page-scoping convention used elsewhere.
* refactor(sidebar): modernize AppSidebar with AntD CSS variables and icons
- Replace hardcoded rgba(0,0,0,X) colors with var(--ant-color-text)
and var(--ant-color-text-secondary) so light/dark adapt automatically.
- Replace rgba(128,128,128,0.15) borders with var(--ant-color-border-secondary)
and rgba(128,128,128,0.18) backgrounds with var(--ant-color-fill-tertiary).
- Drop all body.dark/html[data-theme='ultra-dark'] color forks for
.drawer-brand, .sider-brand, .drawer-close, .sidebar-theme-cycle,
.sidebar-donate (CSS variables already adapt).
- Drop the body.dark Drawer background !important pair; AntD's
colorBgElevated token from the dark algorithm handles it now.
- Replace inline sun/moon SVGs in ThemeCycleButton with AntD's
SunOutlined/MoonOutlined/MoonFilled to match LoginPage/SubPage.
- Convert .sidebar-theme-cycle hover and the menu item selected/hover
highlights from hardcoded #4096ff to color-mix on --ant-color-primary,
keeping !important on menu rules to beat AntD's CSS-in-JS specificity.
* refactor(frontend): swap hardcoded AntD palette colors for CSS variables
The dot/badge/pill styles still hardcoded AntD's default palette values
(#52c41a, #1677ff, #ff4d4f, #fa8c16, #ff4d4f). Replace each with its
semantic --ant-color-* equivalent so they auto-adapt to any theme
customization through ConfigProvider.
- ClientsPage: .dot-green/.dot-blue/.dot-red/.dot-orange/.dot-gray now
use --ant-color-success / -primary / -error / -warning / -text-quaternary.
.bulk-count / .client-card / .client-card.is-selected backgrounds use
color-mix on --ant-color-primary and --ant-color-fill-quaternary, which
also let the body-dark .client-card fork go away.
- XrayMetricsModal: .obs-dot is-alive/is-dead and its pulse keyframe now
build their box-shadow tint via color-mix on --ant-color-success and
--ant-color-error instead of rgba literals.
- IndexPage: .action-update warning color uses --ant-color-warning.
- OutboundsTab: .outbound-card border, .address-pill background, and
.mode-badge tint now use AntD CSS variables; the .xray-page.is-dark
.address-pill fork is gone.
- InboundFormModal/InboundsPage/ClientBulkAddModal: drop the stale
`, #1677ff`/`, #1890ff` fallbacks on var(--ant-color-primary), and
switch .danger-icon to --ant-color-error.
The teal/cyan brand colors (#008771, #3c89e8, #e04141) used by traffic
and pill rows are intentionally kept hardcoded — they are brand-specific
shades, not AntD palette colors.
* refactor(frontend): swap neutral gray rgba literals for AntD CSS variables
Across 12 files the same neutral grays kept reappearing — rgba(128,128,128,
0.06|0.08|0.12|0.15|0.18|0.2|0.25) for borders, dividers, and subtle
backgrounds. Each maps cleanly to an AntD CSS variable that already
adapts to light/dark and to any theme customization through ConfigProvider:
- 0.12–0.18 borders → var(--ant-color-border-secondary)
- 0.2–0.25 borders → var(--ant-color-border)
- 0.06–0.08 backgrounds → var(--ant-color-fill-tertiary)
- 0.02–0.03 card surfaces → var(--ant-color-fill-quaternary)
Card surfaces (InboundList .inbound-card, NodeList .node-card) had a
light/dark fork pair — the variable covers both, so the .is-dark .card
override is gone.
RoutingTab .rule-card.drop-before/after used hardcoded #1677ff for the
inset focus shadow; replaced with var(--ant-color-primary) so reordering
indicators follow the theme primary.
ClientsPage bucketBadgeColor returned hex literals (#ff4d4f, #fa8c16,
#52c41a, rgba gray) for a Badge color prop. Switched to status="error"|
"warning"|"success"|"default" so the dot color now comes from AntD's
semantic palette directly.
* refactor(xray): collapse RoutingTab dark forks into AntD CSS variables
- .criterion-more bg light/dark fork → var(--ant-color-fill-tertiary)
- .xray-page.is-dark .rule-card and .criterion-chip overrides removed;
the rules already use --bg-card and --ant-color-fill-tertiary that
adapt to the theme on their own.
* refactor(frontend): inline style hex literals and Alert icon redundancy
- FinalMaskForm: five DeleteOutlined icons used rgb(255,77,79) inline;
swap for var(--ant-color-error) so they follow theme customization.
- NodesPage: CheckCircleOutlined / CloseCircleOutlined statistic prefixes
switch to var(--ant-color-success) / -error.
- NodeList: ExclamationCircleOutlined warning icons (two callsites) now
use var(--ant-color-warning).
- BasicsTab: four <Alert type="warning"> blocks shipped a custom
ExclamationCircleFilled icon styled to match the warning palette —
exactly the icon and color AntD Alert renders for type="warning" by
default. Replace the icon prop with showIcon and drop the now-unused
ExclamationCircleFilled import.
- JsonEditor: focus-within box-shadow tint now uses color-mix on
--ant-color-primary instead of an rgba(22,119,255,0.1) literal.
* refactor(logs): collapse log-container dark forks to AntD CSS variables
LogModal and XrayLogModal each had a body.dark fork that overrode the
log container's background, border-color, and text color in addition
to the --log-* severity tokens. Background/border/color all map cleanly
to var(--ant-color-fill-tertiary) / var(--ant-color-border) /
var(--ant-color-text) which already adapt to the theme, so only the
severity color tokens remain inside the dark/ultra-dark blocks.
* refactor(xray): drop stale --ant-primary-color fallbacks and hex literals
- RoutingTab .drop-before/.drop-after box-shadow: #1677ff → var(--ant-color-primary)
- OutboundFormModal .random-icon: drop the --ant-primary-color/#1890ff
pair (the old AntD v4 token name with stale fallback) for the v6
--ant-color-primary; .danger-icon hex #ff4d4f → var(--ant-color-error).
- XrayPage .restart-icon: same drop of the --ant-primary-color fallback.
These were all leftovers from the AntD v4 → v6 rename — the v6
--ant-color-primary is already populated by ConfigProvider, so the
fallback hex was dead code that would only trigger if AntD wasn't
mounted.
* refactor(frontend): consolidate margin utility classes into one stylesheet
Page CSS files each carried their own copies of the same atomic margin
utilities (.mt-4, .mt-8, .mb-12, .ml-8, .my-10, ...). The definitions
were identical everywhere they appeared, with each file holding only
the subset it happened to need.
Move all of them into a single styles/utils.css imported once from
main.tsx, and delete the per-page copies from InboundFormModal,
CustomGeoSection, PanelUpdateModal, VersionModal, BasicsTab, NordModal,
OutboundFormModal, and WarpModal. The classes are available globally
on the panel app; login.tsx and subpage.tsx entries do not consume any
of them so they stay untouched.
* refactor(frontend): consolidate shared page-shell rules into one stylesheet
Every panel page CSS file repeated the same wrapper boilerplate — the
--bg-page/--bg-card token triples for light/dark/ultra-dark, the
min-height + background root rule, the .ant-layout transparent reset,
the .content-shell transparent reset, and the .loading-spacer min-height.
That's ~30 identical lines duplicated across IndexPage, ClientsPage,
InboundsPage, XrayPage, SettingsPage, NodesPage, and ApiDocsPage.
Move all of it into styles/page-shell.css and import it once from
main.tsx alongside utils.css and page-cards.css. Each page CSS file
now only contains genuinely page-specific rules (content-area padding
overrides, page-specific tokens like ApiDocs's Swagger --sw-* set).
Also drop the per-page `import '@/styles/page-cards.css'` statements
from the 7 page tsx files now that main.tsx loads it globally.
Net: -211 deleted, +6 inserted in the touched files, plus the new
page-shell.css. .zero-margin (Divider override used by Nord/Warp
modals) folded into utils.css alongside the margin classes.
* refactor(frontend): move default content-area padding to page-shell.css
After page-shell.css landed, six of the seven panel pages still kept an
identical `.X-page .content-area { padding: 24px }` desktop rule, plus
three of them kept an identical `padding: 8px` mobile rule. Hoist both
defaults into page-shell.css under a single 6-page selector group and
delete the per-page copies.
What stays page-specific:
- IndexPage keeps its mobile override (padding 12px + padding-top: 64px
for the fixed drawer handle clearance).
- ApiDocsPage keeps its tighter desktop padding (16px) and its own
mobile padding-top: 56px.
Settings .ldap-no-inbounds also switches from #999 to
var(--ant-color-text-tertiary) for theme adaptation.
* refactor(frontend): hoist .header-row, .icons-only, .summary-card to page-shell.css
Settings and Xray pages both carried identical .header-row /
.header-actions / .header-info rules and an identical six-rule
.icons-only block that styles tabbed page navigation. Clients, Inbounds,
and Nodes all carried identical .summary-card padding rules with the
same mobile reduction. None of these are page-specific.
Consolidate:
- .header-row family → page-shell scoped to .settings-page, .xray-page
- .icons-only family → page-shell global (the class is a deliberate
opt-in marker, no scope needed)
- .summary-card → page-shell scoped to .clients-page, .inbounds-page,
.nodes-page (also fixes InboundsPage's missing scope — its rule was
global and would have matched stray .summary-card uses elsewhere)
InboundsPage.css and NodesPage.css became empty after the move so the
files and their per-page imports are deleted.
* refactor(frontend): hoist .random-icon to utils.css
Three form modals each carried identical .random-icon styles (small
primary-tinted icon next to randomizable inputs):
ClientBulkAddModal, InboundFormModal, OutboundFormModal
Single definition lives in utils.css now. ClientBulkAddModal.css was
just this one rule, so the file and its import are deleted along the way.
.danger-icon is left per file — the margin-left differs slightly
between InboundFormModal (6px) and OutboundFormModal (8px), so it
stays as a page-local rule rather than getting averaged into utils.css.
* refactor(frontend): hoist .danger-icon to utils.css and use it everywhere
InboundFormModal (margin-left 6px) and OutboundFormModal (margin-left
8px) each carried their own .danger-icon, and FinalMaskForm wrote the
same color/cursor/marginLeft trio inline five times. Unify on a single
.danger-icon in utils.css with margin-left: 8px — matching the more
generous OutboundFormModal value — and:
- Drop the per-file .danger-icon copies from InboundFormModal.css and
OutboundFormModal.css.
- Replace the five inline style props in FinalMaskForm.tsx with
className="danger-icon".
The visible change is a 2px wider gap to the right of the delete icons
on InboundFormModal's protocol/peer dividers.
This commit is contained in:
@@ -1,13 +1,4 @@
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
--sw-bg: #1f2026;
|
||||
--sw-bg-soft: #25272e;
|
||||
--sw-bg-input: #15161a;
|
||||
@@ -22,8 +13,6 @@
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
--sw-bg: #0a0a0d;
|
||||
--sw-bg-soft: #131316;
|
||||
--sw-bg-input: #050507;
|
||||
@@ -51,7 +40,7 @@
|
||||
.api-docs-page .docs-wrapper {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import '@/styles/page-cards.css';
|
||||
import './ApiDocsPage.css';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound';
|
||||
import DateTimePicker from '@/components/DateTimePicker';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import './ClientBulkAddModal.css';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
|
||||
.link-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
@@ -62,37 +62,25 @@
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
body.dark .link-panel-text {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.link-panel-anchor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
color: var(--ant-color-primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
||||
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 40%, transparent);
|
||||
transition: background 120ms ease, text-decoration-color 120ms ease;
|
||||
}
|
||||
|
||||
.link-panel-anchor:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
text-decoration-color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
|
||||
body.dark .link-panel-anchor {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .link-panel-anchor:hover {
|
||||
background: rgba(22, 119, 255, 0.16);
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
text-decoration-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,6 @@
|
||||
.clients-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.clients-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.clients-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.clients-page .ant-layout,
|
||||
.clients-page .ant-layout-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.clients-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.clients-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clients-page .content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.clients-page .ant-pagination-options-size-changer,
|
||||
.clients-page .ant-pagination-options-size-changer .ant-select-selector {
|
||||
min-width: 100px !important;
|
||||
}
|
||||
|
||||
.clients-page .loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.clients-page .summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clients-page .summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.client-email-list {
|
||||
@@ -92,11 +42,11 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dot-green { background: #52c41a; }
|
||||
.dot-blue { background: #1677ff; }
|
||||
.dot-red { background: #ff4d4f; }
|
||||
.dot-orange { background: #fa8c16; }
|
||||
.dot-gray { background: rgba(128, 128, 128, 0.6); }
|
||||
.dot-green { background: var(--ant-color-success); }
|
||||
.dot-blue { background: var(--ant-color-primary); }
|
||||
.dot-red { background: var(--ant-color-error); }
|
||||
.dot-orange { background: var(--ant-color-warning); }
|
||||
.dot-gray { background: var(--ant-color-text-quaternary); }
|
||||
|
||||
.status-tag {
|
||||
margin: 0 0 0 4px;
|
||||
@@ -154,32 +104,27 @@
|
||||
|
||||
.card-pagination .ant-pagination-options-size-changer,
|
||||
.card-pagination .ant-pagination-options-size-changer .ant-select-selector {
|
||||
min-width: 88px !important;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 12px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.client-card.is-selected {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .client-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--ant-color-primary);
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
@@ -49,7 +50,6 @@ import { useClients } from '@/hooks/useClients';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
import { IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import LazyMount from '@/components/LazyMount';
|
||||
@@ -58,7 +58,6 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
||||
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
||||
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
||||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||||
import '@/styles/page-cards.css';
|
||||
import './ClientsPage.css';
|
||||
|
||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||
@@ -216,13 +215,12 @@ export default function ClientsPage() {
|
||||
return 'active';
|
||||
}, [expireDiff, trafficDiff]);
|
||||
|
||||
function bucketBadgeColor(bucket: Bucket | null): string {
|
||||
function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
|
||||
switch (bucket) {
|
||||
case 'depleted': return '#ff4d4f';
|
||||
case 'expiring': return '#fa8c16';
|
||||
case 'deactive': return 'rgba(128,128,128,0.6)';
|
||||
case 'active': return '#52c41a';
|
||||
default: return 'rgba(128,128,128,0.6)';
|
||||
case 'depleted': return 'error';
|
||||
case 'expiring': return 'warning';
|
||||
case 'active': return 'success';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +622,7 @@ export default function ClientsPage() {
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<CustomStatistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
|
||||
<Statistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
@@ -632,7 +630,7 @@ export default function ClientsPage() {
|
||||
open={summary.online.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
>
|
||||
<CustomStatistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
||||
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
@@ -641,7 +639,7 @@ export default function ClientsPage() {
|
||||
open={summary.depleted.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
>
|
||||
<CustomStatistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
||||
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
@@ -650,7 +648,7 @@ export default function ClientsPage() {
|
||||
open={summary.expiring.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
>
|
||||
<CustomStatistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
||||
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
@@ -659,11 +657,11 @@ export default function ClientsPage() {
|
||||
open={summary.deactive.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
>
|
||||
<CustomStatistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
||||
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<CustomStatistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
|
||||
<Statistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
@@ -838,7 +836,7 @@ export default function ClientsPage() {
|
||||
checked={selectedRowKeys.includes(row.email)}
|
||||
onChange={(e) => toggleSelect(row.email, e.target.checked)}
|
||||
/>
|
||||
<Badge color={bucketBadgeColor(bucket)} />
|
||||
<Badge status={bucketBadgeStatus(bucket)} />
|
||||
<span className="tag-name">{row.email}</span>
|
||||
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
|
||||
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
.mt-4 { margin-top: 4px; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-12 { margin-top: 12px; }
|
||||
.mb-4 { margin-bottom: 4px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.mb-12 { margin-bottom: 12px; }
|
||||
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary, #1890ff);
|
||||
}
|
||||
|
||||
.danger-icon {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.vless-auth-state {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
@@ -34,9 +15,9 @@
|
||||
|
||||
.advanced-panel {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 12px;
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.advanced-panel__header {
|
||||
@@ -79,9 +60,3 @@
|
||||
padding-inline: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .advanced-panel,
|
||||
html[data-theme='ultra-dark'] .advanced-panel {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
@@ -55,8 +54,8 @@ import {
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
TCP_CONGESTION_OPTION,
|
||||
MODE_OPTION,
|
||||
} from '@/models/inbound.js';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
} from '@/models/inbound';
|
||||
import { DBInbound } from '@/models/dbinbound';
|
||||
import FinalMaskForm from '@/components/FinalMaskForm';
|
||||
import DateTimePicker from '@/components/DateTimePicker';
|
||||
import JsonEditor from '@/components/JsonEditor';
|
||||
@@ -71,11 +70,75 @@ interface InboundFormModalProps {
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
mode: 'add' | 'edit';
|
||||
dbInbound: any;
|
||||
dbInbounds: any[];
|
||||
dbInbound: DBInbound | null;
|
||||
dbInbounds: DBInbound[];
|
||||
availableNodes?: NodeRecord[];
|
||||
}
|
||||
|
||||
interface StreamLike {
|
||||
network?: string;
|
||||
tcp?: { type?: string; request?: { path?: string[] }; acceptProxyProtocol?: boolean };
|
||||
ws?: { path?: string; acceptProxyProtocol?: boolean };
|
||||
grpc?: { serviceName?: string; multiMode?: boolean };
|
||||
httpupgrade?: { path?: string; acceptProxyProtocol?: boolean };
|
||||
xhttp?: { path?: string };
|
||||
security?: string;
|
||||
tls?: { certs?: TlsCert[] };
|
||||
reality?: unknown;
|
||||
externalProxy?: unknown;
|
||||
}
|
||||
|
||||
interface TlsCert {
|
||||
useFile?: boolean;
|
||||
certFile?: string;
|
||||
keyFile?: string;
|
||||
cert?: string;
|
||||
key?: string;
|
||||
ocspStapling?: number;
|
||||
oneTimeLoading?: boolean;
|
||||
usage?: string;
|
||||
buildChain?: boolean;
|
||||
}
|
||||
|
||||
interface VlessClient {
|
||||
id?: string;
|
||||
email?: string;
|
||||
flow?: string;
|
||||
enable?: boolean;
|
||||
subId?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
limitIp?: number;
|
||||
comment?: string;
|
||||
tgId?: string;
|
||||
}
|
||||
|
||||
interface ShadowsocksClient {
|
||||
email?: string;
|
||||
password?: string;
|
||||
method?: string;
|
||||
enable?: boolean;
|
||||
subId?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
limitIp?: number;
|
||||
comment?: string;
|
||||
tgId?: string;
|
||||
}
|
||||
|
||||
interface HttpAccount {
|
||||
user?: string;
|
||||
pass?: string;
|
||||
}
|
||||
|
||||
interface WireguardPeer {
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
psk?: string;
|
||||
allowedIPs: string[];
|
||||
keepAlive?: number;
|
||||
}
|
||||
|
||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||
const PROTOCOLS = Object.values(Protocols) as string[];
|
||||
const TLS_VERSIONS = Object.values(TLS_VERSION_OPTION) as string[];
|
||||
@@ -107,12 +170,12 @@ interface FallbackRow {
|
||||
xver: number;
|
||||
}
|
||||
|
||||
function deriveFallbackDefaults(childDb: any): Omit<FallbackRow, 'rowKey' | 'childId'> {
|
||||
function deriveFallbackDefaults(childDb: DBInbound | null | undefined): Omit<FallbackRow, 'rowKey' | 'childId'> {
|
||||
const out = { name: '', alpn: '', path: '', xver: 0 };
|
||||
if (!childDb) return out;
|
||||
let stream: any;
|
||||
let stream: StreamLike | undefined;
|
||||
try {
|
||||
stream = childDb.toInbound()?.stream;
|
||||
stream = childDb.toInbound()?.stream as StreamLike | undefined;
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
@@ -166,7 +229,9 @@ export default function InboundFormModal({
|
||||
[availableNodes],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const inboundRef = useRef<any>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dbFormRef = useRef<any>(null);
|
||||
const fallbackKeyRef = useRef(0);
|
||||
const advancedTextRef = useRef({ stream: '', sniffing: '', settings: '' });
|
||||
@@ -279,9 +344,9 @@ export default function InboundFormModal({
|
||||
if (!open) return;
|
||||
setFallbackEditing(new Set());
|
||||
if (mode === 'edit' && dbInbound) {
|
||||
const parsed = (Inbound as any).fromJson(dbInbound.toInbound().toJson());
|
||||
const parsed = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||
inboundRef.current = parsed;
|
||||
dbFormRef.current = new (DBInbound as any)(dbInbound);
|
||||
dbFormRef.current = new DBInbound(dbInbound);
|
||||
primeAdvancedJson();
|
||||
if (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN) {
|
||||
loadFallbacks(dbInbound.id);
|
||||
@@ -289,12 +354,12 @@ export default function InboundFormModal({
|
||||
setFallbacks([]);
|
||||
}
|
||||
} else {
|
||||
const ib = new (Inbound as any)();
|
||||
const ib = new Inbound();
|
||||
ib.protocol = Protocols.VLESS;
|
||||
ib.settings = (Inbound as any).Settings.getSettings(Protocols.VLESS);
|
||||
ib.settings = Inbound.Settings.getSettings(Protocols.VLESS);
|
||||
ib.port = RandomUtil.randomInteger(10000, 60000);
|
||||
inboundRef.current = ib;
|
||||
const form = new (DBInbound as any)();
|
||||
const form = new DBInbound();
|
||||
form.enable = true;
|
||||
form.remark = '';
|
||||
form.total = 0;
|
||||
@@ -333,7 +398,7 @@ export default function InboundFormModal({
|
||||
const ib = inboundRef.current;
|
||||
if (mode === 'edit' || !ib) return;
|
||||
ib.protocol = next;
|
||||
ib.settings = (Inbound as any).Settings.getSettings(next);
|
||||
ib.settings = Inbound.Settings.getSettings(next);
|
||||
if (!NODE_ELIGIBLE_PROTOCOLS.has(next) && dbFormRef.current) {
|
||||
dbFormRef.current.nodeId = null;
|
||||
}
|
||||
@@ -352,7 +417,7 @@ export default function InboundFormModal({
|
||||
&& !ib.canEnableTlsFlow()
|
||||
&& Array.isArray(ib.settings.vlesses)
|
||||
) {
|
||||
ib.settings.vlesses.forEach((c: any) => { c.flow = ''; });
|
||||
ib.settings.vlesses.forEach((c: VlessClient) => { c.flow = ''; });
|
||||
}
|
||||
if (next !== 'kcp' && ib.stream.finalmask) {
|
||||
ib.stream.finalmask.udp = [];
|
||||
@@ -379,7 +444,7 @@ export default function InboundFormModal({
|
||||
xver: 0,
|
||||
};
|
||||
if (childId) {
|
||||
const child = (dbInbounds || []).find((ib: any) => ib.id === childId);
|
||||
const child = (dbInbounds || []).find((ib) => ib.id === childId);
|
||||
Object.assign(row, deriveFallbackDefaults(child));
|
||||
}
|
||||
setFallbacks((prev) => [...prev, row]);
|
||||
@@ -402,7 +467,7 @@ export default function InboundFormModal({
|
||||
const onFallbackChildPicked = useCallback((rowKey: string, childId: number) => {
|
||||
setFallbacks((prev) => prev.map((row) => {
|
||||
if (row.rowKey !== rowKey) return row;
|
||||
const child = (dbInbounds || []).find((ib: any) => ib.id === childId);
|
||||
const child = (dbInbounds || []).find((ib) => ib.id === childId);
|
||||
const defaults = deriveFallbackDefaults(child);
|
||||
return { ...row, childId, ...defaults };
|
||||
}));
|
||||
@@ -415,7 +480,7 @@ export default function InboundFormModal({
|
||||
const rederiveFallback = useCallback((rowKey: string) => {
|
||||
setFallbacks((prev) => prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || !row.childId) return row;
|
||||
const child = (dbInbounds || []).find((ib: any) => ib.id === row.childId);
|
||||
const child = (dbInbounds || []).find((ib) => ib.id === row.childId);
|
||||
const defaults = deriveFallbackDefaults(child);
|
||||
return { ...row, ...defaults };
|
||||
}));
|
||||
@@ -432,9 +497,9 @@ export default function InboundFormModal({
|
||||
for (const ib of list) {
|
||||
if (ib.id === masterId) continue;
|
||||
if (existing.has(ib.id)) continue;
|
||||
let stream: any;
|
||||
try { stream = ib.toInbound()?.stream; } catch { continue; }
|
||||
if (!stream || !FALLBACK_ELIGIBLE_TRANSPORTS.has(stream.network)) continue;
|
||||
let stream: StreamLike | undefined;
|
||||
try { stream = ib.toInbound()?.stream as StreamLike | undefined; } catch { continue; }
|
||||
if (!stream || !FALLBACK_ELIGIBLE_TRANSPORTS.has(stream.network ?? '')) continue;
|
||||
const row: FallbackRow = {
|
||||
rowKey: `fb-${++fallbackKeyRef.current}`,
|
||||
childId: ib.id,
|
||||
@@ -456,8 +521,8 @@ export default function InboundFormModal({
|
||||
const list = dbInbounds || [];
|
||||
const masterId = dbInbound?.id;
|
||||
return list
|
||||
.filter((ib: any) => ib.id !== masterId)
|
||||
.map((ib: any) => ({
|
||||
.filter((ib) => ib.id !== masterId)
|
||||
.map((ib) => ({
|
||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||
value: ib.id,
|
||||
}));
|
||||
@@ -488,22 +553,22 @@ export default function InboundFormModal({
|
||||
try { return await fn(); } finally { setSaving(false); }
|
||||
}, []);
|
||||
|
||||
const randomSSPassword = useCallback((target: any) => {
|
||||
const randomSSPassword = useCallback((target: ShadowsocksClient) => {
|
||||
if (target) {
|
||||
target.password = (RandomUtil as any).randomShadowsocksPassword(inboundRef.current.settings.method);
|
||||
target.password = RandomUtil.randomShadowsocksPassword(inboundRef.current.settings.method);
|
||||
refresh();
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const regenWgKeypair = useCallback((target: any) => {
|
||||
const kp = (Wireguard as any).generateKeypair();
|
||||
const regenWgKeypair = useCallback((target: WireguardPeer) => {
|
||||
const kp = Wireguard.generateKeypair();
|
||||
target.publicKey = kp.publicKey;
|
||||
target.privateKey = kp.privateKey;
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const regenInboundWg = useCallback(() => {
|
||||
const kp = (Wireguard as any).generateKeypair();
|
||||
const kp = Wireguard.generateKeypair();
|
||||
inboundRef.current.settings.pubKey = kp.publicKey;
|
||||
inboundRef.current.settings.secretKey = kp.privateKey;
|
||||
refresh();
|
||||
@@ -557,7 +622,7 @@ export default function InboundFormModal({
|
||||
|
||||
const randomizeShortIds = useCallback(() => {
|
||||
if (!inboundRef.current?.stream?.reality) return;
|
||||
inboundRef.current.stream.reality.shortIds = (RandomUtil as any).randomShortIds();
|
||||
inboundRef.current.stream.reality.shortIds = RandomUtil.randomShortIds();
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
@@ -590,7 +655,7 @@ export default function InboundFormModal({
|
||||
refresh();
|
||||
}, [defaultCert, defaultKey, refresh]);
|
||||
|
||||
const matchesVlessAuth = useCallback((block: any, authId: string) => {
|
||||
const matchesVlessAuth = useCallback((block: { id?: string; label?: string } | undefined | null, authId: string) => {
|
||||
if (block?.id === authId) return true;
|
||||
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
if (authId === 'mlkem768') return label.includes('mlkem768');
|
||||
@@ -633,11 +698,11 @@ export default function InboundFormModal({
|
||||
|
||||
const onSSMethodChange = useCallback(() => {
|
||||
const ib = inboundRef.current;
|
||||
ib.settings.password = (RandomUtil as any).randomShadowsocksPassword(ib.settings.method);
|
||||
ib.settings.password = RandomUtil.randomShadowsocksPassword(ib.settings.method);
|
||||
if (ib.isSSMultiUser) {
|
||||
ib.settings.shadowsockses.forEach((c: any) => {
|
||||
ib.settings.shadowsockses.forEach((c: ShadowsocksClient) => {
|
||||
c.method = ib.isSS2022 ? '' : ib.settings.method;
|
||||
c.password = (RandomUtil as any).randomShadowsocksPassword(ib.settings.method);
|
||||
c.password = RandomUtil.randomShadowsocksPassword(ib.settings.method);
|
||||
});
|
||||
} else {
|
||||
ib.settings.shadowsockses = [];
|
||||
@@ -686,7 +751,7 @@ export default function InboundFormModal({
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
inboundRef.current = (Inbound as any).fromJson({
|
||||
inboundRef.current = Inbound.fromJson({
|
||||
port: ib.port,
|
||||
listen: ib.listen,
|
||||
protocol: ib.protocol,
|
||||
@@ -781,17 +846,26 @@ export default function InboundFormModal({
|
||||
})();
|
||||
|
||||
const setAdvancedAllValue = (next: string) => {
|
||||
let parsed: any;
|
||||
let parsedRaw: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(next);
|
||||
parsedRaw = JSON.parse(next);
|
||||
} catch (e) {
|
||||
messageApi.error(`All JSON invalid: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
if (!parsedRaw || typeof parsedRaw !== 'object' || Array.isArray(parsedRaw)) {
|
||||
messageApi.error('All JSON must be an inbound object.');
|
||||
return;
|
||||
}
|
||||
const parsed = parsedRaw as {
|
||||
listen?: string;
|
||||
port?: number | string;
|
||||
protocol?: string;
|
||||
tag?: string;
|
||||
settings?: unknown;
|
||||
sniffing?: unknown;
|
||||
streamSettings?: unknown;
|
||||
};
|
||||
const ib = inboundRef.current;
|
||||
try {
|
||||
if (typeof parsed.listen === 'string') ib.listen = parsed.listen;
|
||||
@@ -857,7 +931,7 @@ export default function InboundFormModal({
|
||||
settings = compactAdvancedJson(advancedTextRef.current.settings, ib.settings.toString(), t('pages.inbounds.advanced.settings'));
|
||||
} catch { return; }
|
||||
|
||||
const payload: any = {
|
||||
const payload: Record<string, unknown> = {
|
||||
up: form.up || 0,
|
||||
down: form.down || 0,
|
||||
total: form.total,
|
||||
@@ -876,14 +950,15 @@ export default function InboundFormModal({
|
||||
if (form.nodeId != null) payload.nodeId = form.nodeId;
|
||||
|
||||
const url = mode === 'edit'
|
||||
? `/panel/api/inbounds/update/${dbInbound.id}`
|
||||
? `/panel/api/inbounds/update/${dbInbound!.id}`
|
||||
: '/panel/api/inbounds/add';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
if (isFallbackHost) {
|
||||
const obj = msg.obj as { id?: number; Id?: number } | null;
|
||||
const masterId = mode === 'edit'
|
||||
? dbInbound.id
|
||||
: ((msg.obj as any)?.id || (msg.obj as any)?.Id);
|
||||
? dbInbound!.id
|
||||
: (obj?.id || obj?.Id);
|
||||
if (masterId) await saveFallbacks(masterId);
|
||||
}
|
||||
onSaved();
|
||||
@@ -1155,8 +1230,8 @@ export default function InboundFormModal({
|
||||
<Form.Item label="Accounts">
|
||||
<Button size="small" onClick={() => {
|
||||
const Account = ib.protocol === Protocols.HTTP
|
||||
? (Inbound as any).HttpSettings.HttpAccount
|
||||
: (Inbound as any).MixedSettings.SocksAccount;
|
||||
? Inbound.HttpSettings.HttpAccount
|
||||
: Inbound.MixedSettings.SocksAccount;
|
||||
ib.settings.addAccount(new Account());
|
||||
refresh();
|
||||
}}>
|
||||
@@ -1164,7 +1239,7 @@ export default function InboundFormModal({
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
{(ib.settings.accounts || []).map((account: any, idx: number) => (
|
||||
{(ib.settings.accounts || []).map((account: HttpAccount, idx: number) => (
|
||||
<Space.Compact key={idx} className="mb-8" block>
|
||||
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||
<Input value={account.user} placeholder="Username"
|
||||
@@ -1337,7 +1412,7 @@ export default function InboundFormModal({
|
||||
<PlusOutlined /> Add peer
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{(ib.settings.peers || []).map((peer: any, idx: number) => (
|
||||
{(ib.settings.peers || []).map((peer: WireguardPeer, idx: number) => (
|
||||
<div key={idx} className="wg-peer">
|
||||
<Divider style={{ margin: '8px 0' }}>
|
||||
Peer {idx + 1}
|
||||
@@ -1906,7 +1981,7 @@ export default function InboundFormModal({
|
||||
<Form.Item label="Disable System Root"><Switch checked={!!ib.stream.tls.disableSystemRoot} onChange={(v) => { ib.stream.tls.disableSystemRoot = v; refresh(); }} /></Form.Item>
|
||||
<Form.Item label="Session Resumption"><Switch checked={!!ib.stream.tls.enableSessionResumption} onChange={(v) => { ib.stream.tls.enableSessionResumption = v; refresh(); }} /></Form.Item>
|
||||
|
||||
{(ib.stream.tls.certs || []).map((cert: any, idx: number) => (
|
||||
{(ib.stream.tls.certs || []).map((cert: TlsCert, idx: number) => (
|
||||
<div key={`cert-${idx}`}>
|
||||
<Form.Item label={t('certificate')}>
|
||||
<Radio.Group value={cert.useFile} buttonStyle="solid" onChange={(e) => { cert.useFile = e.target.value; refresh(); }}>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
@@ -95,16 +95,12 @@
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.dark .value-code {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.value-copy {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -112,7 +108,7 @@ body.dark .value-code {
|
||||
.share-buttons {
|
||||
margin-inline-start: 4px;
|
||||
padding-inline-start: 8px;
|
||||
border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-inline-start: 1px solid var(--ant-color-border);
|
||||
}
|
||||
|
||||
.summary-table {
|
||||
@@ -157,7 +153,7 @@ body.dark .value-code {
|
||||
}
|
||||
|
||||
.link-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
@@ -179,37 +175,25 @@ body.dark .value-code {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
body.dark .link-panel-text {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.link-panel-anchor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
color: var(--ant-color-primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
||||
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 40%, transparent);
|
||||
transition: background 120ms ease, text-decoration-color 120ms ease;
|
||||
}
|
||||
|
||||
.link-panel-anchor:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
text-decoration-color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
|
||||
body.dark .link-panel-anchor {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .link-panel-anchor:hover {
|
||||
background: rgba(22, 119, 255, 0.16);
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
text-decoration-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ClipboardManager,
|
||||
FileManager,
|
||||
} from '@/utils';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { Protocols } from '@/models/inbound';
|
||||
import InfinityIcon from '@/components/InfinityIcon';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { SubSettings } from './useInbounds';
|
||||
|
||||
@@ -32,29 +32,29 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
.inbounds-page .ant-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
.inbounds-page .ant-table-container {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr:first-child > *:first-child {
|
||||
.inbounds-page .ant-table-thead > tr:first-child > *:first-child {
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr:first-child > *:last-child {
|
||||
.inbounds-page .ant-table-thead > tr:first-child > *:last-child {
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > *:first-child {
|
||||
.inbounds-page .ant-table-tbody > tr:last-child > *:first-child {
|
||||
border-end-start-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > *:last-child {
|
||||
.inbounds-page .ant-table-tbody > tr:last-child > *:last-child {
|
||||
border-end-end-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -66,20 +66,15 @@
|
||||
}
|
||||
|
||||
.inbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.dark .inbound-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -142,21 +137,21 @@ body.dark .inbound-card {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ant-card-head {
|
||||
.inbounds-page .ant-card-head {
|
||||
padding: 0 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.ant-card-head-title,
|
||||
.ant-card-extra {
|
||||
.inbounds-page .ant-card-head-title,
|
||||
.inbounds-page .ant-card-extra {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
.inbounds-page .ant-card-body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
.inbounds-page .row-action-trigger {
|
||||
font-size: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ interface DBInboundRecord extends ProtocolFlags {
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
_expiryTime: unknown;
|
||||
_expiryTime: { valueOf(): number } | null;
|
||||
nodeId?: number | null;
|
||||
toInbound: () => {
|
||||
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
.inbounds-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.inbounds-page .ant-layout,
|
||||
.inbounds-page .ant-layout-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Modal,
|
||||
Row,
|
||||
Spin,
|
||||
Statistic,
|
||||
message,
|
||||
} from 'antd';
|
||||
|
||||
@@ -20,14 +20,13 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound.js';
|
||||
import { Inbound } from '@/models/inbound';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
const TextModal = lazy(() => import('@/components/TextModal'));
|
||||
const PromptModal = lazy(() => import('@/components/PromptModal'));
|
||||
|
||||
@@ -37,8 +36,6 @@ import LazyMount from '@/components/LazyMount';
|
||||
const InboundFormModal = lazy(() => import('./InboundFormModal'));
|
||||
const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
|
||||
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
||||
import '@/styles/page-cards.css';
|
||||
import './InboundsPage.css';
|
||||
|
||||
type RowAction =
|
||||
| 'edit'
|
||||
@@ -53,6 +50,12 @@ type RowAction =
|
||||
|
||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||
|
||||
interface ClientMatchTarget {
|
||||
id?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export default function InboundsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
@@ -94,7 +97,7 @@ export default function InboundsPage() {
|
||||
[nodesList],
|
||||
);
|
||||
const hasNodeAttachedInbound = useMemo(
|
||||
() => (dbInbounds || []).some((ib: any) => ib?.nodeId != null),
|
||||
() => (dbInbounds || []).some((ib) => ib?.nodeId != null),
|
||||
[dbInbounds],
|
||||
);
|
||||
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
|
||||
@@ -106,14 +109,14 @@ export default function InboundsPage() {
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formDbInbound, setFormDbInbound] = useState<any>(null);
|
||||
const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
|
||||
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoDbInbound, setInfoDbInbound] = useState<any>(null);
|
||||
const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
|
||||
const [infoClientIndex, setInfoClientIndex] = useState(0);
|
||||
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrDbInbound, setQrDbInbound] = useState<any>(null);
|
||||
const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
|
||||
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
const [textTitle, setTextTitle] = useState('');
|
||||
@@ -128,7 +131,7 @@ export default function InboundsPage() {
|
||||
const [promptLoading, setPromptLoading] = useState(false);
|
||||
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
||||
|
||||
const hostOverrideFor = useCallback((dbInbound: any) => {
|
||||
const hostOverrideFor = useCallback((dbInbound: DBInbound | null) => {
|
||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||
return nodesById.get(dbInbound.nodeId)?.address || '';
|
||||
}, [nodesById]);
|
||||
@@ -172,8 +175,8 @@ export default function InboundsPage() {
|
||||
}
|
||||
}, [promptHandler]);
|
||||
|
||||
const projectChildThroughMaster = useCallback((child: any, master: any) => {
|
||||
const projected = JSON.parse(JSON.stringify(child));
|
||||
const projectChildThroughMaster = useCallback((child: DBInbound, master: DBInbound): DBInbound => {
|
||||
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
|
||||
projected.listen = master.listen;
|
||||
projected.port = master.port;
|
||||
const masterStream = master.toInbound().stream;
|
||||
@@ -183,17 +186,18 @@ export default function InboundsPage() {
|
||||
childInbound.stream.reality = masterStream.reality;
|
||||
childInbound.stream.externalProxy = masterStream.externalProxy;
|
||||
projected.streamSettings = childInbound.stream.toString();
|
||||
return new child.constructor(projected);
|
||||
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
|
||||
return new Ctor(projected);
|
||||
}, []);
|
||||
|
||||
const checkFallback = useCallback((dbInbound: any) => {
|
||||
const checkFallback = useCallback((dbInbound: DBInbound): DBInbound => {
|
||||
const parent = dbInbound?.fallbackParent;
|
||||
if (parent?.masterId) {
|
||||
const master = (dbInbounds as any[]).find((ib: any) => ib.id === parent.masterId);
|
||||
const master = dbInbounds.find((ib) => ib.id === parent.masterId);
|
||||
if (master) return projectChildThroughMaster(dbInbound, master);
|
||||
}
|
||||
if (!(dbInbound?.listen as string | undefined)?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds as any[]) {
|
||||
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds) {
|
||||
if (candidate.id === dbInbound.id) continue;
|
||||
const parsed = candidate.toInbound();
|
||||
if (!parsed.isTcp) continue;
|
||||
@@ -205,11 +209,11 @@ export default function InboundsPage() {
|
||||
return dbInbound;
|
||||
}, [dbInbounds, projectChildThroughMaster]);
|
||||
|
||||
const findClientIndex = useCallback((dbInbound: any, client: any) => {
|
||||
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
|
||||
if (!client) return 0;
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = clients.findIndex((c: any) => {
|
||||
const clients = (inbound?.clients || []) as ClientMatchTarget[];
|
||||
const idx = clients.findIndex((c) => {
|
||||
if (!c) return false;
|
||||
switch (dbInbound.protocol) {
|
||||
case 'trojan':
|
||||
@@ -222,7 +226,7 @@ export default function InboundsPage() {
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, []);
|
||||
|
||||
const exportInboundLinks = useCallback((dbInbound: any) => {
|
||||
const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
|
||||
const projected = checkFallback(dbInbound);
|
||||
openText({
|
||||
title: t('pages.inbounds.exportLinksTitle'),
|
||||
@@ -231,13 +235,13 @@ export default function InboundsPage() {
|
||||
});
|
||||
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
||||
|
||||
const exportInboundClipboard = useCallback((dbInbound: any) => {
|
||||
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
||||
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
|
||||
}, [openText, t]);
|
||||
|
||||
const exportInboundSubs = useCallback((dbInbound: any) => {
|
||||
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const clients = (inbound?.clients || []) as { subId?: string }[];
|
||||
const subLinks: string[] = [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.subURI) {
|
||||
@@ -253,7 +257,7 @@ export default function InboundsPage() {
|
||||
|
||||
const exportAllLinks = useCallback(async () => {
|
||||
const hydrated = await Promise.all(
|
||||
(dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const ib of hydrated) {
|
||||
@@ -265,12 +269,12 @@ export default function InboundsPage() {
|
||||
|
||||
const exportAllSubs = useCallback(async () => {
|
||||
const hydrated = await Promise.all(
|
||||
(dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const ib of hydrated) {
|
||||
const inbound = ib.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const clients = (inbound?.clients || []) as { subId?: string }[];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.subURI) {
|
||||
out.push(subSettings.subURI + c.subId);
|
||||
@@ -303,13 +307,13 @@ export default function InboundsPage() {
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((dbInbound: any) => {
|
||||
const openEdit = useCallback((dbInbound: DBInbound) => {
|
||||
setFormMode('edit');
|
||||
setFormDbInbound(dbInbound);
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback((dbInbound: any) => {
|
||||
const confirmDelete = useCallback((dbInbound: DBInbound) => {
|
||||
modal.confirm({
|
||||
title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
|
||||
content: t('pages.inbounds.deleteConfirmContent'),
|
||||
@@ -323,7 +327,7 @@ export default function InboundsPage() {
|
||||
});
|
||||
}, [modal, refresh, t]);
|
||||
|
||||
const confirmResetTraffic = useCallback((dbInbound: any) => {
|
||||
const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
|
||||
modal.confirm({
|
||||
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
|
||||
content: t('pages.inbounds.resetConfirmContent'),
|
||||
@@ -336,7 +340,7 @@ export default function InboundsPage() {
|
||||
});
|
||||
}, [modal, refresh, t]);
|
||||
|
||||
const confirmClone = useCallback((dbInbound: any) => {
|
||||
const confirmClone = useCallback((dbInbound: DBInbound) => {
|
||||
modal.confirm({
|
||||
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
|
||||
content: t('pages.inbounds.cloneConfirmContent'),
|
||||
@@ -350,7 +354,7 @@ export default function InboundsPage() {
|
||||
raw.clients = [];
|
||||
clonedSettings = JSON.stringify(raw);
|
||||
} catch {
|
||||
clonedSettings = (Inbound as any).Settings.getSettings(baseInbound.protocol).toString();
|
||||
clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
|
||||
}
|
||||
const data = {
|
||||
up: 0,
|
||||
@@ -393,7 +397,7 @@ export default function InboundsPage() {
|
||||
}
|
||||
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
|
||||
|
||||
const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
|
||||
const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
|
||||
// Actions that touch per-client secrets (uuid, password, flow, ...) need
|
||||
// the full payload that the slim list view does not ship. Hydrate first
|
||||
// and then operate on the rehydrated record.
|
||||
@@ -457,21 +461,21 @@ export default function InboundsPage() {
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={12} sm={12} md={8}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.inbounds.totalDownUp')}
|
||||
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={8}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.inbounds.totalUsage')}
|
||||
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
|
||||
prefix={<PieChartOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.inbounds.inboundCount')}
|
||||
value={String(dbInbounds.length)}
|
||||
prefix={<BarsOutlined />}
|
||||
@@ -483,7 +487,7 @@ export default function InboundsPage() {
|
||||
|
||||
<Col span={24}>
|
||||
<InboundList
|
||||
dbInbounds={dbInbounds as any}
|
||||
dbInbounds={dbInbounds}
|
||||
clientCount={clientCount}
|
||||
onlineClients={onlineClients}
|
||||
lastOnlineMap={lastOnlineMap}
|
||||
@@ -496,7 +500,7 @@ export default function InboundsPage() {
|
||||
hasActiveNode={showNodeInfo}
|
||||
onAddInbound={onAddInbound}
|
||||
onGeneralAction={onGeneralAction}
|
||||
onRowAction={onRowAction}
|
||||
onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -512,7 +516,7 @@ export default function InboundsPage() {
|
||||
onSaved={refresh}
|
||||
mode={formMode}
|
||||
dbInbound={formDbInbound}
|
||||
dbInbounds={dbInbounds as any[]}
|
||||
dbInbounds={dbInbounds}
|
||||
availableNodes={nodesList}
|
||||
/>
|
||||
</LazyMount>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Modal } from 'antd';
|
||||
import type { CollapseProps } from 'antd';
|
||||
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { Protocols } from '@/models/inbound';
|
||||
import QrPanel from './QrPanel';
|
||||
import type { SubSettings } from './useInbounds';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.qr-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { DBInbound } from '@/models/dbinbound';
|
||||
import { Protocols } from '@/models/inbound';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
.backup-list {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.dark .backup-list,
|
||||
html[data-theme='ultra-dark'] .backup-list {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.backup-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .backup-item,
|
||||
html[data-theme='ultra-dark'] .backup-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -37,21 +27,11 @@ html[data-theme='ultra-dark'] .backup-item {
|
||||
.backup-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.backup-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: var(--ant-color-text-tertiary);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
body.dark .backup-title,
|
||||
html[data-theme='ultra-dark'] .backup-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .backup-description,
|
||||
html[data-theme='ultra-dark'] .backup-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -14,15 +10,11 @@
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
body.dark .custom-geo-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-geo-alias-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -48,20 +40,12 @@ body.dark .custom-geo-count {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.custom-geo-copyable:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body.dark .custom-geo-ext-code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .custom-geo-copyable:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: var(--ant-color-fill-secondary);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
|
||||
@@ -1,34 +1,3 @@
|
||||
.index-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.index-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.index-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.index-page .ant-layout,
|
||||
.index-page .ant-layout-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.index-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.index-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.index-page .content-area {
|
||||
padding: 12px;
|
||||
@@ -36,156 +5,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.index-page .loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.index-page .ant-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.2s ease, box-shadow 0.25s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
body.dark .index-page .ant-card {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .ant-card {
|
||||
border-color: rgba(255, 255, 255, 0.04);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.index-page .ant-card.ant-card-hoverable:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 0, 0, 0.10);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
body.dark .index-page .ant-card.ant-card-hoverable:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .ant-card.ant-card-hoverable:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.75),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-head {
|
||||
min-height: 44px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-head-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-body {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-body > .ant-row > .ant-col {
|
||||
position: relative;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.index-page .ant-card .ant-card-body > .ant-row > .ant-col + .ant-col::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10%;
|
||||
bottom: 10%;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.10), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .index-page .ant-card .ant-card-body > .ant-row > .ant-col + .ant-col::before {
|
||||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-head {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-actions {
|
||||
border-top-color: rgba(0, 0, 0, 0.06);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.index-page .ant-card .ant-card-actions > li {
|
||||
border-inline-end-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body.dark .index-page .ant-card .ant-card-head {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .index-page .ant-card .ant-card-actions {
|
||||
border-top-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .index-page .ant-card .ant-card-actions > li {
|
||||
border-inline-end-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-head {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-actions {
|
||||
border-top-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-actions > li {
|
||||
border-inline-end-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.index-page .action {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
flex-wrap: nowrap;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.index-page .action .anticon {
|
||||
color: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
body.dark .index-page .action {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
body.dark .index-page .action .anticon {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .action {
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .index-page .action .anticon {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.index-page .action > span:not(.anticon):not(.tg-icon) {
|
||||
@@ -195,23 +19,13 @@ html[data-theme='ultra-dark'] .index-page .action .anticon {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.index-page .action:hover {
|
||||
opacity: 0.75;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.index-page .ant-card-actions > li {
|
||||
margin: 8px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.index-page .action-update {
|
||||
color: #fa8c16;
|
||||
color: var(--ant-color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.index-page .action-update .anticon {
|
||||
color: #fa8c16;
|
||||
color: var(--ant-color-warning);
|
||||
}
|
||||
|
||||
.index-page .history-tag {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
@@ -39,7 +40,6 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
import LazyMount from '@/components/LazyMount';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import StatusCard from './StatusCard';
|
||||
@@ -53,7 +53,6 @@ const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
|
||||
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
|
||||
const XrayLogModal = lazy(() => import('./XrayLogModal'));
|
||||
const VersionModal = lazy(() => import('./VersionModal'));
|
||||
import '@/styles/page-cards.css';
|
||||
import './IndexPage.css';
|
||||
|
||||
export default function IndexPage() {
|
||||
@@ -285,14 +284,14 @@ export default function IndexPage() {
|
||||
<Card title={t('pages.index.operationHours')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title="Xray"
|
||||
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title="OS"
|
||||
value={TimeFormatter.formatSecond(status.uptime)}
|
||||
prefix={<DesktopOutlined />}
|
||||
@@ -306,14 +305,14 @@ export default function IndexPage() {
|
||||
<Card title={t('usage')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.index.memory')}
|
||||
value={SizeFormatter.sizeFormat(status.appStats.mem)}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.index.threads')}
|
||||
value={status.appStats.threads}
|
||||
prefix={<ForkOutlined />}
|
||||
@@ -327,7 +326,7 @@ export default function IndexPage() {
|
||||
<Card title={t('pages.index.overallSpeed')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.index.upload')}
|
||||
value={SizeFormatter.sizeFormat(status.netIO.up)}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
@@ -335,7 +334,7 @@ export default function IndexPage() {
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.index.download')}
|
||||
value={SizeFormatter.sizeFormat(status.netIO.down)}
|
||||
prefix={<ArrowDownOutlined />}
|
||||
@@ -350,14 +349,14 @@ export default function IndexPage() {
|
||||
<Card title={t('pages.index.totalData')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.index.sent')}
|
||||
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
|
||||
prefix={<CloudUploadOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.index.received')}
|
||||
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
|
||||
prefix={<CloudDownloadOutlined />}
|
||||
@@ -392,14 +391,14 @@ export default function IndexPage() {
|
||||
>
|
||||
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={isMobile ? 24 : 12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title="IPv4"
|
||||
value={status.publicIP.ipv4}
|
||||
prefix={<GlobalOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={isMobile ? 24 : 12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title="IPv6"
|
||||
value={status.publicIP.ipv6}
|
||||
prefix={<GlobalOutlined />}
|
||||
@@ -413,14 +412,14 @@ export default function IndexPage() {
|
||||
<Card title={t('pages.index.connectionCount')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title="TCP"
|
||||
value={status.tcpCount}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title="UDP"
|
||||
value={status.udpCount}
|
||||
prefix={<SwapOutlined />}
|
||||
|
||||
@@ -32,9 +32,10 @@
|
||||
word-break: break-word;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.log-stamp {
|
||||
@@ -140,10 +141,6 @@
|
||||
}
|
||||
|
||||
body.dark .log-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
|
||||
--log-stamp: #6aa6ee;
|
||||
--log-debug: #6aa6ee;
|
||||
--log-info: #4ed3a6;
|
||||
@@ -165,12 +162,6 @@ html[data-theme="ultra-dark"] .log-container {
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.logmodal-mobile {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
.logmodal-mobile .ant-modal-content {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : 800}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
className={isMobile ? 'logmodal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={titleNode}
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.dark .version-list,
|
||||
html[data-theme='ultra-dark'] .version-list {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.version-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.version-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .version-list-item,
|
||||
html[data-theme='ultra-dark'] .version-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -11,20 +11,9 @@
|
||||
margin: 8px 8px 16px;
|
||||
padding: 16px 18px 18px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(99, 102, 241, 0.05), rgba(99, 102, 241, 0));
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
body.dark .cpu-chart-wrap {
|
||||
background: linear-gradient(180deg, rgba(129, 140, 248, 0.08), rgba(129, 140, 248, 0));
|
||||
border-color: rgba(129, 140, 248, 0.16);
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .cpu-chart-wrap {
|
||||
background: linear-gradient(180deg, rgba(129, 140, 248, 0.05), rgba(129, 140, 248, 0));
|
||||
border-color: rgba(129, 140, 248, 0.10);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--ant-color-primary) 6%, transparent), transparent);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.cpu-chart-meta {
|
||||
|
||||
@@ -142,7 +142,6 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
|
||||
<Sparkline
|
||||
data={points}
|
||||
labels={labels}
|
||||
vbWidth={840}
|
||||
height={220}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2.2}
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.dark .version-list,
|
||||
html[data-theme='ultra-dark'] .version-list {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.version-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.version-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .version-list-item,
|
||||
html[data-theme='ultra-dark'] .version-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
@@ -110,10 +111,6 @@
|
||||
}
|
||||
|
||||
body.dark .log-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
|
||||
--log-blocked: #ff7575;
|
||||
--log-proxy: #6aa6ee;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
@@ -125,12 +122,6 @@ html[data-theme="ultra-dark"] .log-container {
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.xraylog-modal-mobile {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
.xraylog-modal-mobile .ant-modal-content {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : '80vw'}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
|
||||
@@ -40,23 +40,23 @@
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.18);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 18%, transparent);
|
||||
}
|
||||
|
||||
.obs-dot.is-alive {
|
||||
background: #52c41a;
|
||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.22);
|
||||
background: var(--ant-color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
|
||||
animation: obs-dot-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.obs-dot.is-dead {
|
||||
background: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.22);
|
||||
background: var(--ant-color-error);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-error) 22%, transparent);
|
||||
}
|
||||
|
||||
@keyframes obs-dot-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.22); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(82, 196, 26, 0.06); }
|
||||
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -321,7 +321,6 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
|
||||
<Sparkline
|
||||
data={points}
|
||||
labels={labels}
|
||||
vbWidth={840}
|
||||
height={220}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2.2}
|
||||
|
||||
@@ -12,33 +12,3 @@
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xray-processing-animation .ant-badge-status-dot {
|
||||
animation: xray-pulse 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.xray-running-animation .ant-badge-status-processing::after {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.xray-stop-animation .ant-badge-status-processing::after {
|
||||
border-color: #fa8c16;
|
||||
}
|
||||
|
||||
.xray-error-animation .ant-badge-status-processing::after {
|
||||
border-color: #f5222d;
|
||||
}
|
||||
|
||||
@keyframes xray-pulse {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,6 @@ const XRAY_STATE_KEYS: Record<string, string> = {
|
||||
error: 'pages.index.xrayStatusError',
|
||||
};
|
||||
|
||||
function badgeAnimationClass(color: string): string {
|
||||
if (color === 'green') return 'xray-running-animation';
|
||||
if (color === 'orange') return 'xray-stop-animation';
|
||||
if (color === 'red') return 'xray-error-animation';
|
||||
return 'xray-processing-animation';
|
||||
}
|
||||
|
||||
export default function XrayStatusCard({
|
||||
status,
|
||||
isMobile,
|
||||
@@ -65,12 +58,7 @@ export default function XrayStatusCard({
|
||||
|
||||
const extra =
|
||||
status.xray.state !== 'error' ? (
|
||||
<Badge
|
||||
status="processing"
|
||||
className={`xray-processing-animation ${badgeAnimationClass(status.xray.color)}`}
|
||||
text={stateText}
|
||||
color={status.xray.color}
|
||||
/>
|
||||
<Badge status="processing" text={stateText} color={status.xray.color} />
|
||||
) : (
|
||||
<Popover
|
||||
title={
|
||||
@@ -93,12 +81,7 @@ export default function XrayStatusCard({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
status="processing"
|
||||
text={stateText}
|
||||
color={status.xray.color}
|
||||
className="xray-processing-animation xray-error-animation"
|
||||
/>
|
||||
<Badge status="processing" text={stateText} color={status.xray.color} />
|
||||
</Popover>
|
||||
);
|
||||
|
||||
|
||||
@@ -228,36 +228,6 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--bg-card);
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(99, 102, 241, 0.15);
|
||||
color: var(--color-accent);
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
@@ -402,44 +372,3 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.lang-item:hover,
|
||||
.lang-item:focus-visible {
|
||||
background-color: rgba(99, 102, 241, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lang-item.is-active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lang-item-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,18 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
Layout,
|
||||
Menu,
|
||||
Popover,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
KeyOutlined,
|
||||
LockOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
TranslationOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -105,26 +110,20 @@ export default function LoginPage() {
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const langList = useMemo(
|
||||
() => LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[],
|
||||
const langMenuItems = useMemo(
|
||||
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
|
||||
key: l.value,
|
||||
label: (
|
||||
<Space size={8}>
|
||||
<span aria-hidden="true">{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</Space>
|
||||
),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const themeIcon = !isDark ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : !isUltra ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
);
|
||||
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
@@ -132,35 +131,30 @@ export default function LoginPage() {
|
||||
<Layout className={pageClass}>
|
||||
<Layout.Content className="login-content">
|
||||
<div className="login-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
id="login-theme-cycle"
|
||||
className="theme-cycle"
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('menu.theme')}
|
||||
title={t('menu.theme')}
|
||||
icon={themeIcon}
|
||||
onClick={cycleTheme}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
/>
|
||||
<Popover
|
||||
rootClassName={isDark ? 'dark' : 'light'}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
content={
|
||||
<ul className="lang-list">
|
||||
{langList.map((l) => (
|
||||
<li key={l.value}>
|
||||
<button
|
||||
type="button"
|
||||
className={`lang-item${lang === l.value ? ' is-active' : ''}`}
|
||||
onClick={() => onLangChange(l.value)}
|
||||
>
|
||||
<span className="lang-item-icon" aria-hidden="true">{l.icon}</span>
|
||||
<span className="lang-item-name">{l.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Menu
|
||||
mode="vertical"
|
||||
selectable
|
||||
selectedKeys={[lang]}
|
||||
items={langMenuItems}
|
||||
onClick={({ key }) => onLangChange(key)}
|
||||
style={{ border: 'none', minWidth: 160 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -91,7 +91,6 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||
<Sparkline
|
||||
data={cpuPoints}
|
||||
labels={cpuLabels}
|
||||
vbWidth={640}
|
||||
height={120}
|
||||
stroke="#008771"
|
||||
showGrid
|
||||
@@ -108,7 +107,6 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||
<Sparkline
|
||||
data={memPoints}
|
||||
labels={memLabels}
|
||||
vbWidth={640}
|
||||
height={120}
|
||||
stroke="#7c4dff"
|
||||
showGrid
|
||||
|
||||
@@ -52,20 +52,15 @@
|
||||
}
|
||||
|
||||
.node-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.dark .node-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -135,7 +130,7 @@ body.dark .node-card {
|
||||
.card-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function NodeList({
|
||||
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
|
||||
{record.lastError && (
|
||||
<Tooltip title={record.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
@@ -378,7 +378,7 @@ export default function NodeList({
|
||||
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
|
||||
{statsNode.lastError && (
|
||||
<Tooltip title={statsNode.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
.nodes-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.nodes-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.nodes-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.nodes-page .ant-layout,
|
||||
.nodes-page .ant-layout-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodes-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodes-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nodes-page .content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.nodes-page .loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.nodes-page .summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nodes-page .summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
|
||||
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
@@ -14,12 +14,9 @@ import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { useNodeMutations } from '@/api/queries/useNodeMutations';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
import NodeList from './NodeList';
|
||||
import NodeFormModal from './NodeFormModal';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import '@/styles/page-cards.css';
|
||||
import './NodesPage.css';
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -109,28 +106,28 @@ export default function NodesPage() {
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, isMobile ? 16 : 12]}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.nodes.totalNodes')}
|
||||
value={String(totals.total)}
|
||||
prefix={<CloudServerOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.nodes.onlineNodes')}
|
||||
value={String(totals.online)}
|
||||
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.nodes.offlineNodes')}
|
||||
value={String(totals.offline)}
|
||||
prefix={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
|
||||
prefix={<CloseCircleOutlined style={{ color: 'var(--ant-color-error)' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
<Statistic
|
||||
title={t('pages.nodes.avgLatency')}
|
||||
value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
.api-token-row {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
@@ -78,7 +78,7 @@
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -1,87 +1,9 @@
|
||||
.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;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ 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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
.nested-block {
|
||||
padding: 10px 20px;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
|
||||
.qr-code {
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qr-token {
|
||||
|
||||
@@ -53,49 +53,12 @@
|
||||
|
||||
.qr-code {
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.info-table .ant-descriptions-view,
|
||||
.info-table .ant-descriptions-view table,
|
||||
.info-table .ant-descriptions-view th,
|
||||
.info-table .ant-descriptions-view td {
|
||||
border-color: rgba(0, 0, 0, 0.18) !important;
|
||||
}
|
||||
|
||||
.info-table tbody > tr > th,
|
||||
.info-table tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important;
|
||||
}
|
||||
|
||||
.info-table tbody > tr:last-child > th,
|
||||
.info-table tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.is-dark .info-table .ant-descriptions-view,
|
||||
.is-dark .info-table .ant-descriptions-view table,
|
||||
.is-dark .info-table .ant-descriptions-view th,
|
||||
.is-dark .info-table .ant-descriptions-view td {
|
||||
border-color: rgba(255, 255, 255, 0.18) !important;
|
||||
}
|
||||
|
||||
.is-dark .info-table tbody > tr > th,
|
||||
.is-dark .info-table tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
|
||||
}
|
||||
|
||||
.is-dark .info-table tbody > tr:last-child > th,
|
||||
.is-dark .info-table tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.links-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -158,49 +121,15 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-popover {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--bg-card);
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
.toolbar-btn .anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.is-dark .theme-cycle {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.is-dark .theme-cycle:hover,
|
||||
.is-dark .theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
Descriptions,
|
||||
Dropdown,
|
||||
Layout,
|
||||
Menu,
|
||||
message,
|
||||
Popover,
|
||||
QRCode,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
AppleOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
SettingOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
TranslationOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||
@@ -206,34 +209,20 @@ export default function SubPage() {
|
||||
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
|
||||
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]);
|
||||
|
||||
const langOptions = useMemo(
|
||||
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
||||
value: l.value,
|
||||
const langMenuItems = useMemo(
|
||||
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
|
||||
key: l.value,
|
||||
label: (
|
||||
<>
|
||||
<span aria-label={l.name}>{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</>
|
||||
<Space size={8}>
|
||||
<span aria-hidden="true">{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</Space>
|
||||
),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const themeIcon = !isDark ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : !isUltra ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
);
|
||||
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||
|
||||
const cardTitle = (
|
||||
<Space>
|
||||
@@ -244,32 +233,38 @@ export default function SubPage() {
|
||||
|
||||
const cardExtra = (
|
||||
<Space size={8} align="center">
|
||||
<button
|
||||
type="button"
|
||||
id="sub-theme-cycle"
|
||||
className="theme-cycle"
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('menu.theme')}
|
||||
title={t('menu.theme')}
|
||||
icon={themeIcon}
|
||||
onClick={cycleTheme}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
/>
|
||||
<Popover
|
||||
title={t('pages.settings.language')}
|
||||
rootClassName={isDark ? 'dark' : 'light'}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
content={
|
||||
<Space orientation="vertical" size={10} className="settings-popover">
|
||||
<Select
|
||||
className="lang-select"
|
||||
value={lang}
|
||||
onChange={onLangChange}
|
||||
options={langOptions}
|
||||
/>
|
||||
</Space>
|
||||
<Menu
|
||||
mode="vertical"
|
||||
selectable
|
||||
selectedKeys={[lang]}
|
||||
items={langMenuItems}
|
||||
onClick={({ key }) => onLangChange(key)}
|
||||
style={{ border: 'none', minWidth: 160 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button shape="circle" icon={<SettingOutlined />} />
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('pages.settings.language')}
|
||||
icon={<TranslationOutlined />}
|
||||
/>
|
||||
</Popover>
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint-alert {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
|
||||
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons';
|
||||
import { CloudOutlined, ApiOutlined } from '@ant-design/icons';
|
||||
|
||||
import { OutboundDomainStrategies } from '@/models/outbound.js';
|
||||
import { OutboundDomainStrategies } from '@/models/outbound';
|
||||
import SettingListItem from '@/components/SettingListItem';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import './BasicsTab.css';
|
||||
@@ -205,9 +205,9 @@ export default function BasicsTab({
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-12 hint-alert"
|
||||
title={t('pages.xray.generalConfigsDesc')}
|
||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
||||
/>
|
||||
<SettingListItem
|
||||
title={t('pages.xray.FreedomStrategy')}
|
||||
@@ -299,9 +299,9 @@ export default function BasicsTab({
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-12 hint-alert"
|
||||
title={t('pages.xray.logConfigsDesc')}
|
||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
||||
/>
|
||||
<SettingListItem
|
||||
title={t('pages.xray.logLevel')}
|
||||
@@ -376,9 +376,9 @@ export default function BasicsTab({
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-12 hint-alert"
|
||||
title={t('pages.xray.blockConnectionsConfigsDesc')}
|
||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
||||
/>
|
||||
|
||||
<SettingListItem
|
||||
@@ -427,9 +427,9 @@ export default function BasicsTab({
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-12 hint-alert"
|
||||
title={t('pages.xray.directConnectionsConfigsDesc')}
|
||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
||||
/>
|
||||
|
||||
<SettingListItem
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
.preset-list {
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.dark .preset-list,
|
||||
html[data-theme='ultra-dark'] .preset-list {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.preset-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .preset-row,
|
||||
html[data-theme='ultra-dark'] .preset-row {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -18,36 +18,8 @@
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.row-odd {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
body.dark .row-odd {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.zero-margin {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.my-10 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
.nord-data-table .row-odd {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.server-row {
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
.random-icon {
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.danger-icon {
|
||||
cursor: pointer;
|
||||
color: #ff4d4f;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
Address_Port_Strategy,
|
||||
MODE_OPTION,
|
||||
DNSRuleActions,
|
||||
} from '@/models/outbound.js';
|
||||
} from '@/models/outbound';
|
||||
import FinalMaskForm from '@/components/FinalMaskForm';
|
||||
import JsonEditor from '@/components/JsonEditor';
|
||||
import './OutboundFormModal.css';
|
||||
@@ -469,8 +469,7 @@ export default function OutboundFormModal({
|
||||
);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type OB = any;
|
||||
type OB = Outbound;
|
||||
|
||||
interface FieldProps {
|
||||
ob: OB;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
.outbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
@@ -65,11 +65,7 @@
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
body.dark .address-pill {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
@@ -181,8 +177,8 @@ body.dark .address-pill {
|
||||
font-weight: 500;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { Protocols } from '@/models/outbound.js';
|
||||
import { Protocols } from '@/models/outbound';
|
||||
import OutboundFormModal from './OutboundFormModal';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
}
|
||||
|
||||
.drop-before > td {
|
||||
box-shadow: inset 0 2px 0 0 #1677ff;
|
||||
box-shadow: inset 0 2px 0 0 var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.drop-after > td {
|
||||
box-shadow: inset 0 -2px 0 0 #1677ff;
|
||||
box-shadow: inset 0 -2px 0 0 var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.row-index {
|
||||
@@ -78,11 +78,7 @@
|
||||
font-size: 11px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body.dark .criterion-more {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.criterion-empty {
|
||||
@@ -113,7 +109,7 @@ body.dark .criterion-more {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
@@ -123,11 +119,11 @@ body.dark .criterion-more {
|
||||
}
|
||||
|
||||
.rule-card.drop-before {
|
||||
box-shadow: inset 0 2px 0 0 #1677ff;
|
||||
box-shadow: inset 0 2px 0 0 var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.rule-card.drop-after {
|
||||
box-shadow: inset 0 -2px 0 0 #1677ff;
|
||||
box-shadow: inset 0 -2px 0 0 var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.rule-card-head {
|
||||
@@ -188,7 +184,7 @@ body.dark .criterion-more {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed rgba(128, 128, 128, 0.2);
|
||||
border-top: 1px dashed var(--ant-color-border);
|
||||
}
|
||||
|
||||
.criterion-chip {
|
||||
@@ -197,7 +193,7 @@ body.dark .criterion-more {
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -222,11 +218,3 @@ body.dark .criterion-more {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
body.dark .rule-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .criterion-chip {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -18,32 +18,8 @@
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.row-odd {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
body.dark .row-odd {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.zero-margin {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-8 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.my-10 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
.warp-data-table .row-odd {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.license-actions {
|
||||
|
||||
@@ -1,57 +1,7 @@
|
||||
.xray-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.xray-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.xray-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.xray-page .ant-layout,
|
||||
.xray-page .ant-layout-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.xray-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.xray-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.xray-page .loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.xray-page .header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xray-page .header-actions {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.xray-page .header-info {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xray-page .restart-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.xray-page .restart-result {
|
||||
@@ -69,32 +19,3 @@
|
||||
margin: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.xray-page .icons-only .ant-tabs-nav {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xray-page .icons-only .ant-tabs-nav-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xray-page .icons-only .ant-tabs-nav-list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xray-page .icons-only .ant-tabs-tab {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.xray-page .icons-only .ant-tabs-tab .anticon {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.xray-page .icons-only .ant-tabs-nav-operations {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ import BalancersTab from './BalancersTab';
|
||||
import DnsTab from './DnsTab';
|
||||
import WarpModal from './WarpModal';
|
||||
import NordModal from './NordModal';
|
||||
import '@/styles/page-cards.css';
|
||||
import './XrayPage.css';
|
||||
|
||||
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
|
||||
|
||||
Reference in New Issue
Block a user