mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 09:05:01 +03:00
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.api-docs-page .content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page .content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-docs-page .content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toc-icon {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toc-text {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.toc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toc-link.active .toc-badge {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .docs-header {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-header {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead,
|
||||
html[data-theme='ultra-dark'] .token-hint {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead code,
|
||||
html[data-theme='ultra-dark'] .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-nav {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.dark .toc-link {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-link {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-link:hover {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
body.dark .toc-link.active {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
}
|
||||
|
||||
body.dark .toc-badge {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
body.dark .toc-link.active .toc-badge {
|
||||
background: #58a6ff;
|
||||
color: #0d1117;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType, MouseEvent } from 'react';
|
||||
import { Button, Card, ConfigProvider, Input, Layout, Space } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CompressOutlined,
|
||||
ExpandOutlined,
|
||||
GlobalOutlined,
|
||||
KeyOutlined,
|
||||
LinkOutlined,
|
||||
NodeIndexOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection';
|
||||
import type { Section } from './EndpointSection';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import '@/styles/page-cards.css';
|
||||
import './ApiDocsPage.css';
|
||||
|
||||
const sectionIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||
authentication: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
'custom-geo': GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
'api-tokens': KeyOutlined,
|
||||
'xray-settings': WifiOutlined,
|
||||
subscription: LinkOutlined,
|
||||
websocket: ApiOutlined,
|
||||
};
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const endpointCount = (allSections as Section[]).reduce(
|
||||
(sum, s) => sum + s.endpoints.length,
|
||||
0,
|
||||
);
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => new Set());
|
||||
const [activeSection, setActiveSection] = useState('');
|
||||
|
||||
const sections = useMemo<Section[]>(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!q) return allSections as Section[];
|
||||
return (allSections as Section[])
|
||||
.map((s) => ({
|
||||
...s,
|
||||
endpoints: s.endpoints.filter((e) =>
|
||||
e.path.toLowerCase().includes(q)
|
||||
|| e.summary?.toLowerCase().includes(q)
|
||||
|| e.method.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter((s) => s.endpoints.length > 0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const visibleEndpoints = useMemo(
|
||||
() => sections.reduce((sum, s) => sum + s.endpoints.length, 0),
|
||||
[sections],
|
||||
);
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setCollapsedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = useCallback(() => setCollapsedSections(new Set()), []);
|
||||
const collapseAll = useCallback(
|
||||
() => setCollapsedSections(new Set((allSections as Section[]).map((s) => s.id))),
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollToSection = useCallback((id: string) => (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const id = window.location.hash.slice(1);
|
||||
if (!id) return;
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
};
|
||||
requestAnimationFrame(onHashChange);
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const toc = document.querySelector('.toc-nav');
|
||||
const tocHeight = toc instanceof HTMLElement ? toc.offsetHeight : 56;
|
||||
let current = '';
|
||||
for (const s of sections) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= tocHeight + 20) {
|
||||
current = s.id;
|
||||
}
|
||||
}
|
||||
setActiveSection(current);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
requestAnimationFrame(onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, [sections]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['api-docs-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="docs-wrapper">
|
||||
<header className="docs-header">
|
||||
<h1 className="docs-title">API Documentation</h1>
|
||||
<p className="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{'{ success, msg, obj }'}</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card className="token-card" size="small">
|
||||
<div className="token-card-head">
|
||||
<div className="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<Button type="primary" size="small" href={settingsHref}>
|
||||
Manage tokens
|
||||
</Button>
|
||||
</div>
|
||||
<p className="token-hint">
|
||||
Create, enable, or revoke named Bearer tokens in{' '}
|
||||
<a href={settingsHref}>Settings → Security</a>. Send each request as{' '}
|
||||
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="curl-card" size="small" title="Quick example">
|
||||
<CodeBlock code={curlExample} lang="text" />
|
||||
</Card>
|
||||
|
||||
<div className="toolbar">
|
||||
<Input
|
||||
className="search-bar"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search endpoints by path, method, or description…"
|
||||
allowClear
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="match-count">
|
||||
{visibleEndpoints} / {endpointCount} endpoints
|
||||
</span>
|
||||
)}
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<ExpandOutlined />} onClick={expandAll}>
|
||||
Expand all
|
||||
</Button>
|
||||
<Button size="small" icon={<CompressOutlined />} onClick={collapseAll}>
|
||||
Collapse all
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<nav className="toc-nav">
|
||||
<span className="toc-label">On this page:</span>
|
||||
<div className="toc-links">
|
||||
{sections.map((s) => {
|
||||
const Icon = sectionIcons[s.id];
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
className={`toc-link${activeSection === s.id ? ' active' : ''}`}
|
||||
href={`#${s.id}`}
|
||||
onClick={scrollToSection(s.id)}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
<span className="toc-text">{s.title}</span>
|
||||
<span className="toc-badge">{s.endpoints.length}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{sections.map((s) => (
|
||||
<EndpointSection
|
||||
key={s.id}
|
||||
section={s}
|
||||
icon={sectionIcons[s.id]}
|
||||
collapsed={collapsedSections.has(s.id)}
|
||||
onToggle={() => toggleSection(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
SearchOutlined,
|
||||
ExpandOutlined,
|
||||
CompressOutlined,
|
||||
ApiOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
GlobalOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
WifiOutlined,
|
||||
LinkOutlined,
|
||||
NodeIndexOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const searchQuery = ref('');
|
||||
const collapsedSections = ref(new Set());
|
||||
const activeSection = ref('');
|
||||
|
||||
const sectionIcons = {
|
||||
authentication: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
'custom-geo': GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
'api-tokens': KeyOutlined,
|
||||
'xray-settings': WifiOutlined,
|
||||
subscription: LinkOutlined,
|
||||
websocket: ApiOutlined,
|
||||
};
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
const sections = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!q) return allSections;
|
||||
return allSections
|
||||
.map(s => {
|
||||
const matching = s.endpoints.filter(e =>
|
||||
e.path.toLowerCase().includes(q) ||
|
||||
e.summary?.toLowerCase().includes(q) ||
|
||||
e.method.toLowerCase().includes(q)
|
||||
);
|
||||
return { ...s, endpoints: matching };
|
||||
})
|
||||
.filter(s => s.endpoints.length > 0);
|
||||
});
|
||||
|
||||
const endpointCount = computed(() =>
|
||||
allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
|
||||
);
|
||||
|
||||
const visibleEndpoints = computed(() =>
|
||||
sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
|
||||
);
|
||||
|
||||
function isCollapsed(id) {
|
||||
return collapsedSections.value.has(id);
|
||||
}
|
||||
|
||||
function toggleSection(id) {
|
||||
const s = new Set(collapsedSections.value);
|
||||
if (s.has(id)) s.delete(id); else s.add(id);
|
||||
collapsedSections.value = s;
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
collapsedSections.value = new Set();
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
collapsedSections.value = new Set(allSections.map(s => s.id));
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHash() {
|
||||
const id = window.location.hash.slice(1);
|
||||
if (!id) return;
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}
|
||||
|
||||
let scrollObserver = null;
|
||||
function onScroll() {
|
||||
const toc = document.querySelector('.toc-nav');
|
||||
const tocHeight = toc ? toc.offsetHeight : 56;
|
||||
let current = '';
|
||||
for (const s of sections.value) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= tocHeight + 20) {
|
||||
current = s.id;
|
||||
}
|
||||
}
|
||||
activeSection.value = current;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollObserver = onScroll;
|
||||
window.addEventListener('scroll', scrollObserver, { passive: true });
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToHash();
|
||||
onScroll();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollObserver) {
|
||||
window.removeEventListener('scroll', scrollObserver);
|
||||
}
|
||||
window.removeEventListener('hashchange', scrollToHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<div class="docs-wrapper">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">API Documentation</h1>
|
||||
<p class="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<a-button type="primary" size="small" :href="settingsHref">
|
||||
Manage tokens
|
||||
</a-button>
|
||||
</div>
|
||||
<p class="token-hint">
|
||||
Create, enable, or revoke named Bearer tokens in
|
||||
<a :href="settingsHref">Settings → Security</a>. Send each request as
|
||||
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="curl-card" size="small" title="Quick example">
|
||||
<CodeBlock :code="curlExample" lang="text" />
|
||||
</a-card>
|
||||
|
||||
<div class="toolbar">
|
||||
<a-input-search
|
||||
v-model:value="searchQuery"
|
||||
placeholder="Search endpoints by path, method, or description…"
|
||||
allow-clear
|
||||
class="search-bar"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input-search>
|
||||
<span class="match-count" v-if="searchQuery">
|
||||
{{ visibleEndpoints }} / {{ endpointCount }} endpoints
|
||||
</span>
|
||||
<a-space size="small">
|
||||
<a-button size="small" @click="expandAll">
|
||||
<template #icon><ExpandOutlined /></template>
|
||||
Expand all
|
||||
</a-button>
|
||||
<a-button size="small" @click="collapseAll">
|
||||
<template #icon><CompressOutlined /></template>
|
||||
Collapse all
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<nav class="toc-nav">
|
||||
<span class="toc-label">On this page:</span>
|
||||
<div class="toc-links">
|
||||
<a
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
class="toc-link"
|
||||
:class="{ active: activeSection === s.id }"
|
||||
:href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)"
|
||||
>
|
||||
<component :is="sectionIcons[s.id]" class="toc-icon" />
|
||||
<span class="toc-text">{{ s.title }}</span>
|
||||
<span class="toc-badge">{{ s.endpoints.length }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<EndpointSection
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
:section="s"
|
||||
:icon="sectionIcons[s.id]"
|
||||
:collapsed="isCollapsed(s.id)"
|
||||
@toggle="toggleSection(s.id)"
|
||||
/>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #0a0a0a;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toc-icon {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toc-text {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.toc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toc-link.active .toc-badge {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .docs-header {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-header {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead,
|
||||
html[data-theme='ultra-dark'] .token-hint {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead code,
|
||||
html[data-theme='ultra-dark'] .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .code-block {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-nav {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.dark .toc-link {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-link {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-link:hover {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
body.dark .toc-link.active {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
}
|
||||
|
||||
body.dark .toc-badge {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
body.dark .toc-link.active .toc-badge {
|
||||
background: #58a6ff;
|
||||
color: #0d1117;
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +1,3 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
code: { type: String, default: '' },
|
||||
lang: { type: String, default: 'json' },
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function highlightJson(str) {
|
||||
const escaped = escapeHtml(str);
|
||||
return escaped.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
|
||||
(_m, key, colon, string, number, bool, nil) => {
|
||||
if (colon) return `<span class="json-key">${key}</span>${colon}`;
|
||||
if (string) return `<span class="json-string">${string}</span>`;
|
||||
if (number) return `<span class="json-number">${number}</span>`;
|
||||
if (bool) return `<span class="json-boolean">${bool}</span>`;
|
||||
if (nil) return `<span class="json-null">${nil}</span>`;
|
||||
return _m;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const highlighted = computed(() => {
|
||||
if (props.lang === 'json') {
|
||||
return highlightJson(props.code);
|
||||
}
|
||||
return escapeHtml(props.code);
|
||||
});
|
||||
|
||||
async function copyCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.code);
|
||||
copied.value = true;
|
||||
message.success('Copied');
|
||||
setTimeout(() => { copied.value = false; }, 2000);
|
||||
} catch {
|
||||
message.error('Copy failed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-block-wrapper">
|
||||
<div class="code-toolbar">
|
||||
<span class="lang-badge">{{ lang.toUpperCase() }}</span>
|
||||
<button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
|
||||
<CheckOutlined v-if="copied" />
|
||||
<CopyOutlined v-else />
|
||||
</button>
|
||||
</div>
|
||||
<pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
@@ -127,9 +63,7 @@ async function copyCode() {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.json-key { color: #0550ae; }
|
||||
.json-string { color: #116329; }
|
||||
.json-number { color: #9a6700; }
|
||||
@@ -171,4 +105,3 @@ body.dark .copy-btn:hover {
|
||||
color: #58a6ff;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { ClipboardManager } from '@/utils';
|
||||
import './CodeBlock.css';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function highlightJson(str: string): string {
|
||||
const escaped = escapeHtml(str);
|
||||
return escaped.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
|
||||
(_m, key, colon, string, number, bool, nil) => {
|
||||
if (colon) return `<span class="json-key">${key}</span>${colon}`;
|
||||
if (string) return `<span class="json-string">${string}</span>`;
|
||||
if (number) return `<span class="json-number">${number}</span>`;
|
||||
if (bool) return `<span class="json-boolean">${bool}</span>`;
|
||||
if (nil) return `<span class="json-null">${nil}</span>`;
|
||||
return _m;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const highlighted = useMemo(
|
||||
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
|
||||
[code, lang],
|
||||
);
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await ClipboardManager.copyText(code);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
messageApi.success('Copied');
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
messageApi.error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block-wrapper">
|
||||
{messageContextHolder}
|
||||
<div className="code-toolbar">
|
||||
<span className="lang-badge">{lang.toUpperCase()}</span>
|
||||
<button
|
||||
className={`copy-btn${copied ? ' copied' : ''}`}
|
||||
onClick={copyCode}
|
||||
title={copied ? 'Copied' : 'Copy'}
|
||||
>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className={`code-block lang-${lang}`}>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
.endpoint-row {
|
||||
padding: 14px 8px;
|
||||
margin: 0 -8px;
|
||||
transition: background 0.15s;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.endpoint-row:hover {
|
||||
background: rgba(128, 128, 128, 0.03);
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
body.dark .endpoint-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.dark .endpoint-row + .endpoint-row {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .endpoint-path {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
body.dark .error-label {
|
||||
color: #ff7b72;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import './EndpointRow.css';
|
||||
|
||||
interface Param {
|
||||
name: string;
|
||||
in?: string;
|
||||
type?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
summary?: string;
|
||||
params?: Param[];
|
||||
body?: string;
|
||||
response?: string;
|
||||
errorResponse?: string;
|
||||
}
|
||||
|
||||
const paramColumns: ColumnsType<Param> = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
|
||||
export default function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
|
||||
const tagColor = (methodColors as Record<string, string>)[endpoint.method] || 'default';
|
||||
const hasParams = Array.isArray(endpoint.params) && endpoint.params.length > 0;
|
||||
|
||||
return (
|
||||
<div className="endpoint-row">
|
||||
<div className="endpoint-header">
|
||||
<Tag color={tagColor} className="method-tag">{endpoint.method}</Tag>
|
||||
<code className="endpoint-path">{endpoint.path}</code>
|
||||
</div>
|
||||
|
||||
{endpoint.summary && (
|
||||
<p
|
||||
className="endpoint-summary"
|
||||
dangerouslySetInnerHTML={{ __html: safeInlineHtml(endpoint.summary) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasParams && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Parameters</div>
|
||||
<Table
|
||||
columns={paramColumns}
|
||||
dataSource={endpoint.params}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.body && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Request body</div>
|
||||
<CodeBlock code={endpoint.body} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.response && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Response</div>
|
||||
<CodeBlock code={endpoint.response} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.errorResponse && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label error-label">Error response</div>
|
||||
<CodeBlock code={endpoint.errorResponse} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
|
||||
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
|
||||
|
||||
const paramColumns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-header">
|
||||
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
|
||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</div>
|
||||
|
||||
<p v-if="endpoint.summary" class="endpoint-summary" v-html="safeInlineHtml(endpoint.summary)"></p>
|
||||
|
||||
<div v-if="hasParams" class="endpoint-block">
|
||||
<div class="block-label">Parameters</div>
|
||||
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<CodeBlock :code="endpoint.body" lang="json" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<CodeBlock :code="endpoint.response" lang="json" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.errorResponse" class="endpoint-block">
|
||||
<div class="block-label error-label">Error response</div>
|
||||
<CodeBlock :code="endpoint.errorResponse" lang="json" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.endpoint-row {
|
||||
padding: 14px 0;
|
||||
margin: 0;
|
||||
transition: background 0.15s;
|
||||
border-radius: 6px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
.endpoint-row:hover {
|
||||
background: rgba(128, 128, 128, 0.03);
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .endpoint-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.dark .endpoint-row + .endpoint-row {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .endpoint-path {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
body.dark .error-label {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
</style>
|
||||
+2
-65
@@ -1,63 +1,3 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
import { safeInlineHtml } from './endpoints.js';
|
||||
|
||||
const props = defineProps({
|
||||
section: { type: Object, required: true },
|
||||
icon: { type: [Object, Function], default: null },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const endpointLabel = computed(() =>
|
||||
props.section.endpoints.length === 1
|
||||
? '1 endpoint'
|
||||
: `${props.section.endpoints.length} endpoints`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="section.id" class="api-section">
|
||||
<div class="section-header" @click="emit('toggle')">
|
||||
<div class="section-header-left">
|
||||
<DownOutlined v-if="!collapsed" class="collapse-icon" />
|
||||
<RightOutlined v-else class="collapse-icon" />
|
||||
<component v-if="icon" :is="icon" class="section-icon" />
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
</div>
|
||||
<span class="endpoint-count">{{ endpointLabel }}</span>
|
||||
</div>
|
||||
<p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
|
||||
|
||||
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
|
||||
<div class="block-label">Response headers</div>
|
||||
<a-table
|
||||
:columns="[{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, { title: 'Description', dataIndex: 'desc', key: 'desc' }]"
|
||||
:data-source="section.subHeader"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="name"
|
||||
>
|
||||
<template #bodyCell="{ column, text }">
|
||||
<span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
|
||||
<template v-else>{{ text }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="endpoints">
|
||||
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
@@ -131,7 +71,7 @@ const endpointLabel = computed(() =>
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
.section-block-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -148,9 +88,7 @@ const endpointLabel = computed(() =>
|
||||
.endpoints > :first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
@@ -181,7 +119,7 @@ body.dark .section-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
body.dark .section-block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
@@ -189,4 +127,3 @@ body.dark .endpoint-count {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import EndpointRow from './EndpointRow';
|
||||
import type { Endpoint } from './EndpointRow';
|
||||
import { safeInlineHtml } from './endpoints.js';
|
||||
import './EndpointSection.css';
|
||||
|
||||
interface SubHeader {
|
||||
name: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
endpoints: Endpoint[];
|
||||
subHeader?: SubHeader[];
|
||||
}
|
||||
|
||||
interface EndpointSectionProps {
|
||||
section: Section;
|
||||
icon?: ComponentType<{ className?: string }> | null;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const subHeaderColumns: ColumnsType<SubHeader> = [
|
||||
{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 },
|
||||
{
|
||||
title: 'Description',
|
||||
dataIndex: 'desc',
|
||||
key: 'desc',
|
||||
render: (value: string) => (
|
||||
<span dangerouslySetInnerHTML={{ __html: safeInlineHtml(value || '') }} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function EndpointSection({
|
||||
section,
|
||||
icon: Icon = null,
|
||||
collapsed = false,
|
||||
onToggle,
|
||||
}: EndpointSectionProps) {
|
||||
const endpointLabel = section.endpoints.length === 1
|
||||
? '1 endpoint'
|
||||
: `${section.endpoints.length} endpoints`;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="api-section">
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-header-left">
|
||||
{collapsed ? <RightOutlined className="collapse-icon" /> : <DownOutlined className="collapse-icon" />}
|
||||
{Icon && <Icon className="section-icon" />}
|
||||
<h2 className="section-title">{section.title}</h2>
|
||||
</div>
|
||||
<span className="endpoint-count">{endpointLabel}</span>
|
||||
</div>
|
||||
|
||||
{section.description && !collapsed && (
|
||||
<p
|
||||
className="section-description"
|
||||
dangerouslySetInnerHTML={{ __html: safeInlineHtml(section.description) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section.subHeader && !collapsed && (
|
||||
<div className="sub-header-block">
|
||||
<div className="section-block-label">Response headers</div>
|
||||
<Table
|
||||
columns={subHeaderColumns}
|
||||
dataSource={section.subHeader}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="endpoints" style={{ display: collapsed ? 'none' : undefined }}>
|
||||
{section.endpoints.map((endpoint, idx) => (
|
||||
<EndpointRow key={idx} endpoint={endpoint} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
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;
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
|
||||
]);
|
||||
|
||||
interface ApiMsg {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
interface ClientBulkAddModalProps {
|
||||
open: boolean;
|
||||
inbounds: InboundOption[];
|
||||
ipLimitEnable?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
emailMethod: number;
|
||||
firstNum: number;
|
||||
lastNum: number;
|
||||
emailPrefix: string;
|
||||
emailPostfix: string;
|
||||
quantity: number;
|
||||
subId: string;
|
||||
comment: string;
|
||||
flow: string;
|
||||
limitIp: number;
|
||||
totalGB: number;
|
||||
expiryTime: number;
|
||||
inboundIds: number[];
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
emailMethod: 0,
|
||||
firstNum: 1,
|
||||
lastNum: 1,
|
||||
emailPrefix: '',
|
||||
emailPostfix: '',
|
||||
quantity: 1,
|
||||
subId: '',
|
||||
comment: '',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
inboundIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function ClientBulkAddModal({
|
||||
open,
|
||||
inbounds,
|
||||
ipLimitEnable = false,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ClientBulkAddModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [delayedStart, setDelayedStart] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setForm(emptyForm());
|
||||
setDelayedStart(false);
|
||||
|
||||
}, [open]);
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const flowCapableIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row?.tlsFlowCapable) ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const showFlow = useMemo(
|
||||
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
|
||||
[form.inboundIds, flowCapableIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFlow && form.flow) {
|
||||
|
||||
update('flow', '');
|
||||
}
|
||||
}, [showFlow, form.flow]);
|
||||
|
||||
const inboundOptions = useMemo(
|
||||
() => (inbounds || [])
|
||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
||||
.map((ib) => ({
|
||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||
value: ib.id,
|
||||
})),
|
||||
[inbounds],
|
||||
);
|
||||
|
||||
const expiryDate = useMemo<Dayjs | null>(
|
||||
() => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||
[form.expiryTime],
|
||||
);
|
||||
|
||||
const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0;
|
||||
|
||||
function buildEmails(): string[] {
|
||||
const method = form.emailMethod;
|
||||
const out: string[] = [];
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (method > 1) {
|
||||
start = form.firstNum;
|
||||
end = form.lastNum + 1;
|
||||
} else {
|
||||
start = 0;
|
||||
end = form.quantity;
|
||||
}
|
||||
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||
const useNum = method > 1;
|
||||
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||
for (let i = start; i < end; i++) {
|
||||
let email = '';
|
||||
if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
|
||||
email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||
out.push(email);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
|
||||
messageApi.error(t('pages.clients.selectInbound'));
|
||||
return;
|
||||
}
|
||||
const emails = buildEmails();
|
||||
if (emails.length === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
|
||||
try {
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const client = {
|
||||
email,
|
||||
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||
id: RandomUtil.randomUUID(),
|
||||
password: RandomUtil.randomLowerAndNum(16),
|
||||
auth: RandomUtil.randomLowerAndNum(16),
|
||||
flow: showFlow ? (form.flow || '') : '',
|
||||
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
||||
expiryTime: form.expiryTime,
|
||||
limitIp: Number(form.limitIp) || 0,
|
||||
comment: form.comment,
|
||||
enable: true,
|
||||
};
|
||||
const payload = { client, inboundIds: form.inboundIds };
|
||||
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise<ApiMsg>;
|
||||
}));
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
let firstError = '';
|
||||
for (const msg of results) {
|
||||
if (msg?.success) ok++;
|
||||
else {
|
||||
failed++;
|
||||
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
||||
} else {
|
||||
messageApi.warning(firstError
|
||||
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
|
||||
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
||||
}
|
||||
onSaved?.();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.clients.bulk')}
|
||||
okText={t('create')}
|
||||
cancelText={t('close')}
|
||||
confirmLoading={saving}
|
||||
mask={{ closable: false }}
|
||||
width={640}
|
||||
onOk={submit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
|
||||
<Form.Item label={t('pages.clients.attachedInbounds')} required>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={form.inboundIds}
|
||||
onChange={(v) => update('inboundIds', v)}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
showSearch
|
||||
filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.method')}>
|
||||
<Select
|
||||
value={form.emailMethod}
|
||||
onChange={(v) => update('emailMethod', v)}
|
||||
options={[
|
||||
{ value: 0, label: 'Random' },
|
||||
{ value: 1, label: 'Random + Prefix' },
|
||||
{ value: 2, label: 'Random + Prefix + Num' },
|
||||
{ value: 3, label: 'Random + Prefix + Num + Postfix' },
|
||||
{ value: 4, label: 'Prefix + Num + Postfix' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{form.emailMethod > 1 && (
|
||||
<>
|
||||
<Form.Item label={t('pages.clients.first')}>
|
||||
<InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.clients.last')}>
|
||||
<InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{form.emailMethod > 0 && (
|
||||
<Form.Item label={t('pages.clients.prefix')}>
|
||||
<Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{form.emailMethod > 2 && (
|
||||
<Form.Item label={t('pages.clients.postfix')}>
|
||||
<Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{form.emailMethod < 2 && (
|
||||
<Form.Item label={t('pages.clients.clientCount')}>
|
||||
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={
|
||||
<>
|
||||
{t('subscription.title')}
|
||||
<SyncOutlined
|
||||
className="random-icon"
|
||||
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
|
||||
/>
|
||||
</>
|
||||
}>
|
||||
<Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('comment')}>
|
||||
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
|
||||
</Form.Item>
|
||||
|
||||
{showFlow && (
|
||||
<Form.Item label={t('pages.clients.flow')}>
|
||||
<Select
|
||||
value={form.flow}
|
||||
onChange={(v) => update('flow', v)}
|
||||
style={{ width: 220 }}
|
||||
options={[
|
||||
{ value: '', label: t('none') },
|
||||
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{ipLimitEnable && (
|
||||
<Form.Item label={t('pages.clients.limitIp')}>
|
||||
<InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('pages.clients.totalGB')}>
|
||||
<InputNumber value={form.totalGB} min={0} step={0.1} onChange={(v) => update('totalGB', Number(v) || 0)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.delayedStart')}>
|
||||
<Switch
|
||||
checked={delayedStart}
|
||||
onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{delayedStart ? (
|
||||
<Form.Item label={t('pages.clients.expireDays')}>
|
||||
<InputNumber
|
||||
value={delayedExpireDays}
|
||||
min={0}
|
||||
onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label={t('pages.inbounds.expireDate')}>
|
||||
<DateTimePicker
|
||||
value={expiryDate}
|
||||
onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
inbounds: { type: Array, default: () => [] },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
emailMethod: 0,
|
||||
firstNum: 1,
|
||||
lastNum: 1,
|
||||
emailPrefix: '',
|
||||
emailPostfix: '',
|
||||
quantity: 1,
|
||||
subId: '',
|
||||
comment: '',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
inboundIds: [],
|
||||
});
|
||||
|
||||
const flowCapableIds = computed(() => {
|
||||
const ids = new Set();
|
||||
for (const row of props.inbounds || []) {
|
||||
if (row?.tlsFlowCapable) ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
const showFlow = computed(() =>
|
||||
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
|
||||
);
|
||||
|
||||
watch(showFlow, (next) => {
|
||||
if (!next) form.flow = '';
|
||||
});
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||
});
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
|
||||
]);
|
||||
|
||||
const inboundOptions = computed(() =>
|
||||
(props.inbounds || [])
|
||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
|
||||
.map((ib) => ({
|
||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||
value: ib.id,
|
||||
})),
|
||||
);
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
form.emailMethod = 0;
|
||||
form.firstNum = 1;
|
||||
form.lastNum = 1;
|
||||
form.emailPrefix = '';
|
||||
form.emailPostfix = '';
|
||||
form.quantity = 1;
|
||||
form.subId = '';
|
||||
form.comment = '';
|
||||
form.flow = '';
|
||||
form.limitIp = 0;
|
||||
form.totalGB = 0;
|
||||
form.expiryTime = 0;
|
||||
form.inboundIds = [];
|
||||
delayedStart.value = false;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function buildEmails() {
|
||||
const method = form.emailMethod;
|
||||
const out = [];
|
||||
let start;
|
||||
let end;
|
||||
if (method > 1) {
|
||||
start = form.firstNum;
|
||||
end = form.lastNum + 1;
|
||||
} else {
|
||||
start = 0;
|
||||
end = form.quantity;
|
||||
}
|
||||
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||
const useNum = method > 1;
|
||||
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||
for (let i = start; i < end; i++) {
|
||||
let email = '';
|
||||
if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
|
||||
email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||
out.push(email);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
|
||||
message.error(t('pages.clients.selectInbound'));
|
||||
return;
|
||||
}
|
||||
const emails = buildEmails();
|
||||
if (emails.length === 0) return;
|
||||
|
||||
saving.value = true;
|
||||
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
|
||||
try {
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const client = {
|
||||
email,
|
||||
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||
id: RandomUtil.randomUUID(),
|
||||
password: RandomUtil.randomLowerAndNum(16),
|
||||
auth: RandomUtil.randomLowerAndNum(16),
|
||||
flow: showFlow.value ? (form.flow || '') : '',
|
||||
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
||||
expiryTime: form.expiryTime,
|
||||
limitIp: Number(form.limitIp) || 0,
|
||||
comment: form.comment,
|
||||
enable: true,
|
||||
};
|
||||
const payload = { client, inboundIds: form.inboundIds };
|
||||
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
|
||||
}));
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
let firstError = '';
|
||||
for (const msg of results) {
|
||||
if (msg?.success) ok++;
|
||||
else {
|
||||
failed++;
|
||||
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
||||
} else {
|
||||
message.warning(firstError
|
||||
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
|
||||
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
||||
}
|
||||
emit('saved');
|
||||
close();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" :width="640" @ok="submit" @cancel="close">
|
||||
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('pages.clients.attachedInbounds')" required>
|
||||
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions"
|
||||
:placeholder="t('pages.clients.selectInbound')" :show-search="true"
|
||||
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.clients.method')">
|
||||
<a-select v-model:value="form.emailMethod">
|
||||
<a-select-option :value="0">Random</a-select-option>
|
||||
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
|
||||
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
|
||||
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
|
||||
<a-input v-model:value="form.emailPrefix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
|
||||
<a-input v-model:value="form.emailPostfix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
|
||||
<a-input-number v-model:value="form.quantity" :min="1" :max="100" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||
</template>
|
||||
<a-input v-model:value="form.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="showFlow" :label="t('pages.clients.flow')">
|
||||
<a-select v-model:value="form.flow" :style="{ width: '220px' }">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.clients.totalGB')">
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else :label="t('pages.inbounds.expireDate')">
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
/* Client form modal — additional layout overrides if needed. */
|
||||
@@ -0,0 +1,524 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import DateTimePicker from '@/components/DateTimePicker';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import './ClientFormModal.css';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
|
||||
]);
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
type Mode = 'add' | 'edit';
|
||||
|
||||
interface SaveMetaEdit {
|
||||
isEdit: true;
|
||||
email: string;
|
||||
attach: number[];
|
||||
detach: number[];
|
||||
}
|
||||
|
||||
interface SaveMetaCreate {
|
||||
isEdit: false;
|
||||
}
|
||||
|
||||
interface SaveCreatePayload {
|
||||
client: Record<string, unknown>;
|
||||
inboundIds: number[];
|
||||
}
|
||||
|
||||
interface ClientFormModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
client: ClientRecord | null;
|
||||
inbounds: InboundOption[];
|
||||
attachedIds?: number[];
|
||||
ipLimitEnable?: boolean;
|
||||
tgBotEnable?: boolean;
|
||||
save: (
|
||||
payload: Record<string, unknown> | SaveCreatePayload,
|
||||
meta: SaveMetaEdit | SaveMetaCreate,
|
||||
) => Promise<ApiMsg | null>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
email: string;
|
||||
subId: string;
|
||||
uuid: string;
|
||||
password: string;
|
||||
auth: string;
|
||||
flow: string;
|
||||
reverseTag: string;
|
||||
totalGB: number;
|
||||
expiryDate: Dayjs | null;
|
||||
delayedStart: boolean;
|
||||
delayedDays: number;
|
||||
limitIp: number;
|
||||
tgId: number;
|
||||
comment: string;
|
||||
enable: boolean;
|
||||
inboundIds: number[];
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
email: '',
|
||||
subId: '',
|
||||
uuid: '',
|
||||
password: '',
|
||||
auth: '',
|
||||
flow: '',
|
||||
reverseTag: '',
|
||||
totalGB: 0,
|
||||
expiryDate: null,
|
||||
delayedStart: false,
|
||||
delayedDays: 0,
|
||||
limitIp: 0,
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
enable: true,
|
||||
inboundIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bytesToGB(bytes: number): number {
|
||||
if (!bytes || bytes <= 0) return 0;
|
||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
|
||||
function gbToBytes(gb: number): number {
|
||||
if (!gb || gb <= 0) return 0;
|
||||
return Math.round(gb * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
export default function ClientFormModal({
|
||||
open,
|
||||
mode,
|
||||
client,
|
||||
inbounds,
|
||||
attachedIds = [],
|
||||
ipLimitEnable = false,
|
||||
tgBotEnable = false,
|
||||
save,
|
||||
onOpenChange,
|
||||
}: ClientFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const isEdit = mode === 'edit';
|
||||
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (isEdit && client) {
|
||||
const et = Number(client.expiryTime) || 0;
|
||||
const next: FormState = {
|
||||
...emptyForm(),
|
||||
email: client.email || '',
|
||||
subId: client.subId || '',
|
||||
uuid: client.uuid || '',
|
||||
password: client.password || '',
|
||||
auth: client.auth || '',
|
||||
flow: client.flow || '',
|
||||
reverseTag: client.reverse?.tag || '',
|
||||
totalGB: bytesToGB(client.totalGB || 0),
|
||||
limitIp: client.limitIp || 0,
|
||||
tgId: Number(client.tgId) || 0,
|
||||
comment: client.comment || '',
|
||||
enable: !!client.enable,
|
||||
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
||||
};
|
||||
if (et < 0) {
|
||||
next.delayedStart = true;
|
||||
next.delayedDays = Math.round(et / -86400000);
|
||||
next.expiryDate = null;
|
||||
} else {
|
||||
next.delayedStart = false;
|
||||
next.delayedDays = 0;
|
||||
next.expiryDate = et > 0 ? dayjs(et) : null;
|
||||
}
|
||||
setForm(next);
|
||||
void loadIps();
|
||||
} else {
|
||||
setForm({
|
||||
...emptyForm(),
|
||||
email: RandomUtil.randomLowerAndNum(9),
|
||||
uuid: RandomUtil.randomUUID(),
|
||||
subId: RandomUtil.randomLowerAndNum(16),
|
||||
password: RandomUtil.randomLowerAndNum(16),
|
||||
auth: RandomUtil.randomLowerAndNum(16),
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, isEdit]);
|
||||
|
||||
const flowCapableIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row?.tlsFlowCapable) ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const vlessLikeIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row && row.protocol === 'vless') ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const showFlow = useMemo(
|
||||
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
|
||||
[form.inboundIds, flowCapableIds],
|
||||
);
|
||||
|
||||
const showReverseTag = useMemo(
|
||||
() => (form.inboundIds || []).some((id) => vlessLikeIds.has(id)),
|
||||
[form.inboundIds, vlessLikeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFlow && form.flow) {
|
||||
|
||||
update('flow', '');
|
||||
}
|
||||
}, [showFlow, form.flow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showReverseTag && form.reverseTag) {
|
||||
|
||||
update('reverseTag', '');
|
||||
}
|
||||
}, [showReverseTag, form.reverseTag]);
|
||||
|
||||
const inboundOptions = useMemo(
|
||||
() => (inbounds || [])
|
||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
||||
.map((ib) => ({
|
||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||
value: ib.id,
|
||||
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
|
||||
})),
|
||||
[inbounds],
|
||||
);
|
||||
|
||||
async function loadIps() {
|
||||
if (!isEdit || !client?.email) return;
|
||||
setIpsLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||
if (!msg?.success) { setClientIps([]); return; }
|
||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
||||
} finally {
|
||||
setIpsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearIps() {
|
||||
if (!isEdit || !client?.email) return;
|
||||
setIpsClearing(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
|
||||
if (msg?.success) setClientIps([]);
|
||||
} finally {
|
||||
setIpsClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.email || form.email.trim() === '') {
|
||||
messageApi.error(`${t('pages.clients.email')} *`);
|
||||
return;
|
||||
}
|
||||
if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) {
|
||||
messageApi.error(t('pages.clients.selectInbound'));
|
||||
return;
|
||||
}
|
||||
const expiryTime = form.delayedStart
|
||||
? -86400000 * (Number(form.delayedDays) || 0)
|
||||
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
|
||||
const clientPayload: Record<string, unknown> = {
|
||||
email: form.email.trim(),
|
||||
subId: form.subId,
|
||||
id: form.uuid,
|
||||
password: form.password,
|
||||
auth: form.auth,
|
||||
flow: showFlow ? (form.flow || '') : '',
|
||||
totalGB: gbToBytes(form.totalGB),
|
||||
expiryTime,
|
||||
limitIp: Number(form.limitIp) || 0,
|
||||
tgId: Number(form.tgId) || 0,
|
||||
comment: form.comment,
|
||||
enable: !!form.enable,
|
||||
};
|
||||
const reverseTag = showReverseTag ? (form.reverseTag || '').trim() : '';
|
||||
if (reverseTag) {
|
||||
clientPayload.reverse = { tag: reverseTag };
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let msg;
|
||||
if (isEdit && client) {
|
||||
const original = new Set(attachedIds || []);
|
||||
const next = new Set(form.inboundIds || []);
|
||||
const toAttach = [...next].filter((id) => !original.has(id));
|
||||
const toDetach = [...original].filter((id) => !next.has(id));
|
||||
msg = await save(clientPayload, {
|
||||
isEdit: true,
|
||||
email: client.email,
|
||||
attach: toAttach,
|
||||
detach: toDetach,
|
||||
});
|
||||
} else {
|
||||
msg = await save(
|
||||
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||
{ isEdit: false },
|
||||
);
|
||||
}
|
||||
if (msg?.success) close();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
|
||||
destroyOnHidden
|
||||
okText={isEdit ? t('save') : t('create')}
|
||||
cancelText={t('cancel')}
|
||||
okButtonProps={{ loading: submitting }}
|
||||
width={720}
|
||||
onOk={onSubmit}
|
||||
onCancel={close}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.email')} required>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input
|
||||
value={form.email}
|
||||
placeholder={t('pages.clients.email')}
|
||||
style={{ flex: 1 }}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
<Button onClick={() => update('email', RandomUtil.randomLowerAndNum(12))}>↻</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.subId')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
||||
<Button onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}>↻</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
||||
<Button onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))}>↻</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.password')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||
<Button onClick={() => update('password', RandomUtil.randomLowerAndNum(16))}>↻</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.uuid')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
||||
<Button onClick={() => update('uuid', RandomUtil.randomUUID())}>↻</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={ipLimitEnable ? 8 : 12}>
|
||||
<Form.Item label={t('pages.clients.totalGB')}>
|
||||
<InputNumber value={form.totalGB} min={0} step={0.1} style={{ width: '100%' }}
|
||||
onChange={(v) => update('totalGB', Number(v) || 0)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{ipLimitEnable && (
|
||||
<Col xs={24} md={4}>
|
||||
<Form.Item label={t('pages.clients.limitIp')}>
|
||||
<InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => update('limitIp', Number(v) || 0)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
{form.delayedStart ? (
|
||||
<Form.Item label={t('pages.clients.expireDays')}>
|
||||
<InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => update('delayedDays', Number(v) || 0)} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label={t('pages.clients.expiryTime')}>
|
||||
<DateTimePicker
|
||||
value={form.expiryDate}
|
||||
onChange={(d) => update('expiryDate', d || null)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.delayedStart')}>
|
||||
<Switch
|
||||
checked={form.delayedStart}
|
||||
onChange={(v) => {
|
||||
update('delayedStart', v);
|
||||
if (v) update('expiryDate', null);
|
||||
else update('delayedDays', 0);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{(showFlow || showReverseTag) && (
|
||||
<Row gutter={16}>
|
||||
{showFlow && (
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.flow')}>
|
||||
<Select
|
||||
value={form.flow}
|
||||
onChange={(v) => update('flow', v)}
|
||||
options={[
|
||||
{ value: '', label: t('none') },
|
||||
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
{showReverseTag && (
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.reverseTag')}>
|
||||
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
|
||||
onChange={(e) => update('reverseTag', e.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
{tgBotEnable && (
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.telegramId')}>
|
||||
<InputNumber value={form.tgId} min={0} controls={false}
|
||||
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
|
||||
onChange={(v) => update('tgId', Number(v) || 0)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} md={tgBotEnable ? 12 : 24}>
|
||||
<Form.Item label={t('pages.clients.comment')}>
|
||||
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={form.inboundIds}
|
||||
onChange={(v) => update('inboundIds', v)}
|
||||
options={inboundOptions}
|
||||
showSearch
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
|
||||
</Form.Item>
|
||||
|
||||
{isEdit && ipLimitEnable && (
|
||||
<Form.Item label={t('pages.clients.ipLog')}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button size="small" loading={ipsLoading} onClick={loadIps}>{t('refresh')}</Button>
|
||||
<Button size="small" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
|
||||
{t('pages.clients.clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
{clientIps.length > 0 ? (
|
||||
<div>
|
||||
{clientIps.map((ip, idx) => (
|
||||
<Tag key={idx} color="blue" style={{ marginBottom: 4 }}>{ip}</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Tag>{t('tgbot.noIpRecord')}</Tag>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add' },
|
||||
client: { type: Object, default: null },
|
||||
inbounds: { type: Array, default: () => [] },
|
||||
attachedIds: { type: Array, default: () => [] },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
save: { type: Function, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const submitting = ref(false);
|
||||
const form = reactive(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
email: '',
|
||||
subId: '',
|
||||
uuid: '',
|
||||
password: '',
|
||||
auth: '',
|
||||
flow: '',
|
||||
reverseTag: '',
|
||||
totalGB: 0,
|
||||
expiryDate: null,
|
||||
delayedStart: false,
|
||||
delayedDays: 0,
|
||||
limitIp: 0,
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
enable: true,
|
||||
inboundIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const isEdit = computed(() => props.mode === 'edit');
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(next) => {
|
||||
if (!next) return;
|
||||
Object.assign(form, emptyForm());
|
||||
if (isEdit.value && props.client) {
|
||||
form.email = props.client.email || '';
|
||||
form.subId = props.client.subId || '';
|
||||
form.uuid = props.client.uuid || '';
|
||||
form.password = props.client.password || '';
|
||||
form.auth = props.client.auth || '';
|
||||
form.flow = props.client.flow || '';
|
||||
form.reverseTag = props.client.reverse?.tag || '';
|
||||
form.totalGB = bytesToGB(props.client.totalGB || 0);
|
||||
const et = Number(props.client.expiryTime) || 0;
|
||||
if (et < 0) {
|
||||
form.delayedStart = true;
|
||||
form.delayedDays = Math.round(et / -86400000);
|
||||
form.expiryDate = null;
|
||||
} else {
|
||||
form.delayedStart = false;
|
||||
form.delayedDays = 0;
|
||||
form.expiryDate = et > 0 ? dayjs(et) : null;
|
||||
}
|
||||
form.limitIp = props.client.limitIp || 0;
|
||||
form.tgId = Number(props.client.tgId) || 0;
|
||||
form.comment = props.client.comment || '';
|
||||
form.enable = !!props.client.enable;
|
||||
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
|
||||
void loadIps();
|
||||
} else {
|
||||
form.email = RandomUtil.randomLowerAndNum(9);
|
||||
form.uuid = RandomUtil.randomUUID();
|
||||
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||
form.password = RandomUtil.randomLowerAndNum(16);
|
||||
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function bytesToGB(bytes) {
|
||||
if (!bytes || bytes <= 0) return 0;
|
||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
|
||||
function gbToBytes(gb) {
|
||||
if (!gb || gb <= 0) return 0;
|
||||
return Math.round(gb * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
|
||||
]);
|
||||
|
||||
const inboundOptions = computed(() =>
|
||||
(props.inbounds || [])
|
||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
|
||||
.map((ib) => ({
|
||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||
value: ib.id,
|
||||
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
|
||||
})),
|
||||
);
|
||||
|
||||
const flowCapableIds = computed(() => {
|
||||
const ids = new Set();
|
||||
for (const row of props.inbounds || []) {
|
||||
if (row?.tlsFlowCapable) ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
const showFlow = computed(() =>
|
||||
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
|
||||
);
|
||||
|
||||
watch(showFlow, (next) => {
|
||||
if (!next) form.flow = '';
|
||||
});
|
||||
|
||||
const vlessLikeIds = computed(() => {
|
||||
const ids = new Set();
|
||||
for (const row of props.inbounds || []) {
|
||||
if (row && row.protocol === 'vless') {
|
||||
ids.add(row.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
const showReverseTag = computed(() =>
|
||||
(form.inboundIds || []).some((id) => vlessLikeIds.value.has(id)),
|
||||
);
|
||||
|
||||
watch(showReverseTag, (next) => {
|
||||
if (!next) form.reverseTag = '';
|
||||
});
|
||||
|
||||
const clientIps = ref([]);
|
||||
const ipsLoading = ref(false);
|
||||
const ipsClearing = ref(false);
|
||||
|
||||
async function loadIps() {
|
||||
if (!isEdit.value || !props.client?.email) return;
|
||||
ipsLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
|
||||
if (!msg?.success) { clientIps.value = []; return; }
|
||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
|
||||
} finally {
|
||||
ipsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearIps() {
|
||||
if (!isEdit.value || !props.client?.email) return;
|
||||
ipsClearing.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
|
||||
if (msg?.success) clientIps.value = [];
|
||||
} finally {
|
||||
ipsClearing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function regenerateUUID() {
|
||||
form.uuid = RandomUtil.randomUUID();
|
||||
}
|
||||
|
||||
function regeneratePassword() {
|
||||
form.password = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
function regenerateAuth() {
|
||||
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
function regenerateSubId() {
|
||||
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
function regenerateEmail() {
|
||||
form.email = RandomUtil.randomLowerAndNum(12);
|
||||
}
|
||||
|
||||
function onDelayedStartToggle(next) {
|
||||
if (next) {
|
||||
form.expiryDate = null;
|
||||
} else {
|
||||
form.delayedDays = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.email || form.email.trim() === '') {
|
||||
message.error(`${t('pages.clients.email')} *`);
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
|
||||
message.error(t('pages.clients.selectInbound'));
|
||||
return;
|
||||
}
|
||||
const expiryTime = form.delayedStart
|
||||
? -86400000 * (Number(form.delayedDays) || 0)
|
||||
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
|
||||
const clientPayload = {
|
||||
email: form.email.trim(),
|
||||
subId: form.subId,
|
||||
id: form.uuid,
|
||||
password: form.password,
|
||||
auth: form.auth,
|
||||
flow: showFlow.value ? (form.flow || '') : '',
|
||||
totalGB: gbToBytes(form.totalGB),
|
||||
expiryTime,
|
||||
limitIp: Number(form.limitIp) || 0,
|
||||
tgId: Number(form.tgId) || 0,
|
||||
comment: form.comment,
|
||||
enable: !!form.enable,
|
||||
};
|
||||
const reverseTag = showReverseTag.value ? (form.reverseTag || '').trim() : '';
|
||||
if (reverseTag) {
|
||||
clientPayload.reverse = { tag: reverseTag };
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
let msg;
|
||||
if (isEdit.value) {
|
||||
const original = new Set(props.attachedIds || []);
|
||||
const next = new Set(form.inboundIds || []);
|
||||
const toAttach = [...next].filter((id) => !original.has(id));
|
||||
const toDetach = [...original].filter((id) => !next.has(id));
|
||||
msg = await props.save(clientPayload, {
|
||||
isEdit: true,
|
||||
email: props.client.email,
|
||||
attach: toAttach,
|
||||
detach: toDetach,
|
||||
});
|
||||
} else {
|
||||
msg = await props.save(
|
||||
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||
{ isEdit: false },
|
||||
);
|
||||
}
|
||||
if (msg?.success) close();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
|
||||
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
|
||||
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
|
||||
<a-form layout="vertical" :model="form">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.email')" required>
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" style="flex: 1" />
|
||||
<a-button @click="regenerateEmail">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.subId')">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.subId" style="flex: 1" />
|
||||
<a-button @click="regenerateSubId">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.hysteriaAuth')">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.auth" style="flex: 1" />
|
||||
<a-button @click="regenerateAuth">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.password')">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.password" style="flex: 1" />
|
||||
<a-button @click="regeneratePassword">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.uuid')">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.uuid" style="flex: 1" />
|
||||
<a-button @click="regenerateUUID">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="ipLimitEnable ? 8 : 12">
|
||||
<a-form-item :label="t('pages.clients.totalGB')">
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="ipLimitEnable" :xs="24" :md="4">
|
||||
<a-form-item :label="t('pages.clients.limitIp')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
|
||||
<a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item v-else :label="t('pages.clients.expiryTime')">
|
||||
<a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||
<a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row v-if="showFlow || showReverseTag" :gutter="16">
|
||||
<a-col v-if="showFlow" :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.flow')">
|
||||
<a-select v-model:value="form.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="showReverseTag" :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.reverseTag')">
|
||||
<a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col v-if="tgBotEnable" :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.clients.telegramId')">
|
||||
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
|
||||
:placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="tgBotEnable ? 12 : 24">
|
||||
<a-form-item :label="t('pages.clients.comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item :label="t('pages.clients.attachedInbounds')" :required="!isEdit">
|
||||
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
|
||||
:placeholder="t('pages.clients.selectInbound')"
|
||||
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-switch v-model:checked="form.enable" />
|
||||
<span style="margin-left: 8px">{{ t('enable') }}</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
|
||||
<a-space style="margin-bottom: 8px">
|
||||
<a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
|
||||
<a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
|
||||
{{ t('pages.clients.clearAll') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
<div v-if="clientIps.length > 0">
|
||||
<a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
|
||||
</div>
|
||||
<a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table.block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td:first-child {
|
||||
width: 140px;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-large-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.link-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.link-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link-panel-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
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);
|
||||
border-radius: 4px;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import './ClientInfoModal.css';
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
}
|
||||
|
||||
interface ClientInfoModalProps {
|
||||
open: boolean;
|
||||
client: ClientRecord | null;
|
||||
inboundsById: Record<number, InboundOption>;
|
||||
isOnline: boolean;
|
||||
subSettings?: SubSettings;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
|
||||
|
||||
export default function ClientInfoModal({
|
||||
open,
|
||||
client,
|
||||
inboundsById,
|
||||
isOnline,
|
||||
subSettings = DEFAULT_SUB,
|
||||
onOpenChange,
|
||||
}: ClientInfoModalProps) {
|
||||
const { datepicker } = useDatepicker();
|
||||
const expiryLabel = (ts?: number) => (!ts || ts <= 0 ? '∞' : IntlUtil.formatDate(ts, datepicker));
|
||||
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [links, setLinks] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setLinks([]);
|
||||
return;
|
||||
}
|
||||
if (!client?.subId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.get(
|
||||
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
|
||||
) as ApiMsg<string[]>;
|
||||
if (cancelled) return;
|
||||
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, client?.subId]);
|
||||
|
||||
const traffic = client?.traffic || null;
|
||||
const totalBytes = client?.totalGB || 0;
|
||||
const used = (traffic?.up || 0) + (traffic?.down || 0);
|
||||
const remaining = useMemo(() => {
|
||||
if (totalBytes <= 0) return -1;
|
||||
const r = totalBytes - used;
|
||||
return r > 0 ? r : 0;
|
||||
}, [totalBytes, used]);
|
||||
|
||||
const subLink = useMemo(() => {
|
||||
if (!client?.subId || !subSettings?.subURI) return '';
|
||||
return subSettings.subURI + client.subId;
|
||||
}, [client?.subId, subSettings?.subURI]);
|
||||
|
||||
const subJsonLink = useMemo(() => {
|
||||
if (!client?.subId) return '';
|
||||
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
|
||||
return subSettings.subJsonURI + client.subId;
|
||||
}, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
|
||||
|
||||
const showSubscription = !!(subSettings?.enable && client?.subId);
|
||||
|
||||
async function copyValue(text: string) {
|
||||
if (!text) return;
|
||||
const ok = await ClipboardManager.copyText(String(text));
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={client ? client.email : t('info')}
|
||||
footer={null}
|
||||
width={640}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
{client && (
|
||||
<>
|
||||
<table className="info-table block">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('pages.clients.online')}</td>
|
||||
<td>
|
||||
{client.enable && isOnline
|
||||
? <Tag color="green">{t('pages.clients.online')}</Tag>
|
||||
: <Tag>{t('pages.clients.offline')}</Tag>}
|
||||
<span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('status')}</td>
|
||||
<td>
|
||||
<Tag color={client.enable ? 'green' : 'default'}>
|
||||
{client.enable ? t('enabled') : t('disabled')}
|
||||
</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.email')}</td>
|
||||
<td>
|
||||
{client.email
|
||||
? <Tag color="green">{client.email}</Tag>
|
||||
: <Tag color="red">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.subId')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
|
||||
{client.subId && (
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{client.uuid && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.uuid')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.uuid}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{client.password && (
|
||||
<tr>
|
||||
<td>{t('password')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.password}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{client.auth && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.auth')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.auth}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.clients.flow')}</td>
|
||||
<td>
|
||||
{client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.traffic')}</td>
|
||||
<td>
|
||||
<Tag>
|
||||
↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
|
||||
{' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
|
||||
</Tag>
|
||||
<span className="hint">
|
||||
{SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('remained')}</td>
|
||||
<td>
|
||||
{remaining < 0
|
||||
? <Tag color="purple">∞</Tag>
|
||||
: <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.expireDate')}</td>
|
||||
<td>
|
||||
{!client.expiryTime || client.expiryTime <= 0
|
||||
? <Tag color="purple">∞</Tag>
|
||||
: <Tag>{expiryLabel(client.expiryTime)}</Tag>}
|
||||
{(client.expiryTime ?? 0) > 0 && (
|
||||
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.clients.ipLimit')}</td>
|
||||
<td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.createdAt')}</td>
|
||||
<td><Tag>{dateLabel(client.createdAt)}</Tag></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.updatedAt')}</td>
|
||||
<td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
|
||||
</tr>
|
||||
{client.comment && (
|
||||
<tr>
|
||||
<td>{t('pages.clients.comment')}</td>
|
||||
<td><Tag className="info-large-tag">{client.comment}</Tag></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.clients.attachedInbounds')}</td>
|
||||
<td>
|
||||
<div className="chips">
|
||||
{(client.inboundIds || []).map((id) => {
|
||||
const ib = inboundsById[id];
|
||||
return (
|
||||
<Tag key={id} color="blue">
|
||||
{ib ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})` : `#${id}`}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{(!client.inboundIds || client.inboundIds.length === 0) && (
|
||||
<span className="hint">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => (
|
||||
<div key={idx} className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{`${t('pages.clients.link')} ${idx + 1}`}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{link}</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSubscription && subLink && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{t('subscription.title')}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
|
||||
</div>
|
||||
{subJsonLink && (
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">JSON</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CopyOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { SizeFormatter, IntlUtil, ClipboardManager, HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
client: { type: Object, default: null },
|
||||
inboundsById: { type: Object, default: () => ({}) },
|
||||
isOnline: { type: Boolean, default: false },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const linksLoading = ref(false);
|
||||
|
||||
const traffic = computed(() => props.client?.traffic || null);
|
||||
const totalBytes = computed(() => props.client?.totalGB || 0);
|
||||
const used = computed(() => (traffic.value?.up || 0) + (traffic.value?.down || 0));
|
||||
const remaining = computed(() => {
|
||||
if (totalBytes.value <= 0) return -1;
|
||||
const r = totalBytes.value - used.value;
|
||||
return r > 0 ? r : 0;
|
||||
});
|
||||
|
||||
const subLink = computed(() => {
|
||||
if (!props.client?.subId || !props.subSettings?.subURI) return '';
|
||||
return props.subSettings.subURI + props.client.subId;
|
||||
});
|
||||
|
||||
const subJsonLink = computed(() => {
|
||||
if (!props.client?.subId) return '';
|
||||
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
|
||||
return props.subSettings.subJsonURI + props.client.subId;
|
||||
});
|
||||
|
||||
const showSubscription = computed(
|
||||
() => !!(props.subSettings?.enable && props.client?.subId),
|
||||
);
|
||||
|
||||
function expiryLabel(ts) {
|
||||
if (!ts || ts <= 0) return '∞';
|
||||
return IntlUtil.formatDate(ts);
|
||||
}
|
||||
|
||||
function expiryRelative(ts) {
|
||||
if (!ts || ts <= 0) return '';
|
||||
return IntlUtil.formatRelativeTime(ts);
|
||||
}
|
||||
|
||||
function lastOnlineLabel(ts) {
|
||||
if (!ts || ts <= 0) return '-';
|
||||
return IntlUtil.formatDate(ts);
|
||||
}
|
||||
|
||||
function dateLabel(ts) {
|
||||
if (!ts || ts <= 0) return '-';
|
||||
return IntlUtil.formatDate(ts);
|
||||
}
|
||||
|
||||
async function copyValue(text) {
|
||||
if (!text) return;
|
||||
const ok = await ClipboardManager.copyText(String(text));
|
||||
if (ok) message.success(t('copied'));
|
||||
}
|
||||
|
||||
async function loadLinks() {
|
||||
if (!props.client?.subId) {
|
||||
links.value = [];
|
||||
return;
|
||||
}
|
||||
linksLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get(
|
||||
`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
|
||||
);
|
||||
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
linksLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) loadLinks();
|
||||
else links.value = [];
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="640" @cancel="close">
|
||||
<template v-if="client">
|
||||
<table class="info-table block">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ t('pages.clients.online') }}</td>
|
||||
<td>
|
||||
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
|
||||
<span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('status') }}</td>
|
||||
<td>
|
||||
<a-tag :color="client.enable ? 'green' : 'default'">
|
||||
{{ client.enable ? t('enabled') : t('disabled') }}
|
||||
</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.clients.email') }}</td>
|
||||
<td>
|
||||
<a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
|
||||
<a-tag v-else color="red">{{ t('none') }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.clients.subId') }}</td>
|
||||
<td>
|
||||
<a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
|
||||
<a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="client.uuid">
|
||||
<td>{{ t('pages.clients.uuid') }}</td>
|
||||
<td>
|
||||
<a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
|
||||
<a-button size="small" type="text" @click="copyValue(client.uuid)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="client.password">
|
||||
<td>{{ t('password') }}</td>
|
||||
<td>
|
||||
<a-tag class="info-large-tag">{{ client.password }}</a-tag>
|
||||
<a-button size="small" type="text" @click="copyValue(client.password)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="client.auth">
|
||||
<td>{{ t('pages.clients.auth') }}</td>
|
||||
<td>
|
||||
<a-tag class="info-large-tag">{{ client.auth }}</a-tag>
|
||||
<a-button size="small" type="text" @click="copyValue(client.auth)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.clients.flow') }}</td>
|
||||
<td>
|
||||
<a-tag v-if="client.flow">{{ client.flow }}</a-tag>
|
||||
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.inbounds.traffic') }}</td>
|
||||
<td>
|
||||
<a-tag>
|
||||
↑ {{ SizeFormatter.sizeFormat(traffic?.up || 0) }}
|
||||
/ ↓ {{ SizeFormatter.sizeFormat(traffic?.down || 0) }}
|
||||
</a-tag>
|
||||
<span class="hint">
|
||||
{{ SizeFormatter.sizeFormat(used) }}
|
||||
/
|
||||
{{ totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>
|
||||
<a-tag v-if="remaining < 0" color="purple">∞</a-tag>
|
||||
<a-tag v-else :color="remaining > 0 ? '' : 'red'">
|
||||
{{ SizeFormatter.sizeFormat(remaining) }}
|
||||
</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.inbounds.expireDate') }}</td>
|
||||
<td>
|
||||
<a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple">∞</a-tag>
|
||||
<a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
|
||||
<span v-if="client.expiryTime > 0" class="hint">{{ expiryRelative(client.expiryTime) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.clients.ipLimit') }}</td>
|
||||
<td>
|
||||
<a-tag v-if="!client.limitIp">∞</a-tag>
|
||||
<a-tag v-else>{{ client.limitIp }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.inbounds.createdAt') }}</td>
|
||||
<td>
|
||||
<a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.inbounds.updatedAt') }}</td>
|
||||
<td>
|
||||
<a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="client.comment">
|
||||
<td>{{ t('pages.clients.comment') }}</td>
|
||||
<td>
|
||||
<a-tag class="info-large-tag">{{ client.comment }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{{ t('pages.clients.attachedInbounds') }}</td>
|
||||
<td>
|
||||
<div class="chips">
|
||||
<a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
|
||||
<template v-if="inboundsById[id]">
|
||||
{{ inboundsById[id].remark || `#${id}` }} ({{ inboundsById[id].protocol }}:{{ inboundsById[id].port }})
|
||||
</template>
|
||||
<template v-else>#{{ id }}</template>
|
||||
</a-tag>
|
||||
<span v-if="!client.inboundIds || client.inboundIds.length === 0" class="hint">—</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<template v-if="links.length > 0">
|
||||
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
|
||||
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
|
||||
<div class="link-panel-header">
|
||||
<a-tag color="green">{{ `${t('pages.clients.link')} ${idx + 1}` }}</a-tag>
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-button size="small" @click="copyValue(link)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<code class="link-panel-text">{{ link }}</code>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="showSubscription && subLink">
|
||||
<a-divider>{{ t('subscription.title') }}</a-divider>
|
||||
<div class="link-panel">
|
||||
<div class="link-panel-header">
|
||||
<a-tag color="green">{{ t('subscription.title') }}</a-tag>
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-button size="small" @click="copyValue(subLink)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
|
||||
</div>
|
||||
|
||||
<div v-if="subJsonLink" class="link-panel">
|
||||
<div class="link-panel-header">
|
||||
<a-tag color="green">JSON</a-tag>
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-button size="small" @click="copyValue(subJsonLink)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink }}</a>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table.block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td:first-child {
|
||||
width: 140px;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-large-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.link-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.link-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link-panel-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
:global(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);
|
||||
border-radius: 4px;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
||||
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);
|
||||
}
|
||||
|
||||
:global(body.dark) .link-panel-anchor {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:global(body.dark) .link-panel-anchor:hover {
|
||||
background: rgba(22, 119, 255, 0.16);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Modal, Spin } from 'antd';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import QrPanel from '@/pages/inbounds/QrPanel';
|
||||
import type { ClientRecord } from '@/hooks/useClients';
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
}
|
||||
|
||||
interface ClientQrModalProps {
|
||||
open: boolean;
|
||||
client: ClientRecord | null;
|
||||
subSettings?: SubSettings;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
|
||||
|
||||
export default function ClientQrModal({
|
||||
open,
|
||||
client,
|
||||
subSettings = DEFAULT_SUB,
|
||||
onOpenChange,
|
||||
}: ClientQrModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [links, setLinks] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const subLink = useMemo(() => {
|
||||
if (!client?.subId || !subSettings?.enable || !subSettings?.subURI) return '';
|
||||
return subSettings.subURI + client.subId;
|
||||
}, [client?.subId, subSettings?.enable, subSettings?.subURI]);
|
||||
|
||||
const subJsonLink = useMemo(() => {
|
||||
if (!client?.subId || !subSettings?.enable) return '';
|
||||
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
|
||||
return subSettings.subJsonURI + client.subId;
|
||||
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
|
||||
|
||||
const hasAnything = !!subLink || !!subJsonLink || links.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !client?.subId) {
|
||||
setLinks([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get(
|
||||
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
|
||||
) as ApiMsg<string[]>;
|
||||
if (!cancelled) {
|
||||
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, client?.subId]);
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const out: { key: string; label: string; children: React.ReactNode }[] = [];
|
||||
if (subLink) {
|
||||
out.push({
|
||||
key: 'sub',
|
||||
label: t('subscription.title'),
|
||||
children: <QrPanel value={subLink} remark={`${client?.email || ''} — ${t('subscription.title')}`} />,
|
||||
});
|
||||
}
|
||||
if (subJsonLink) {
|
||||
out.push({
|
||||
key: 'subJson',
|
||||
label: `${t('subscription.title')} (JSON)`,
|
||||
children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
|
||||
});
|
||||
}
|
||||
links.forEach((link, idx) => {
|
||||
out.push({
|
||||
key: `l${idx}`,
|
||||
label: `${t('pages.clients.link')} ${idx + 1}`,
|
||||
children: <QrPanel value={link} remark={`${client?.email || ''} #${idx + 1}`} />,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}, [subLink, subJsonLink, links, client?.email, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveKey([]);
|
||||
return;
|
||||
}
|
||||
setActiveKey(items.length > 0 ? [items[0].key] : []);
|
||||
}, [open, items]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={client ? client.email : t('qrCode')}
|
||||
footer={null}
|
||||
width={520}
|
||||
centered
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{!client?.subId && !loading && (
|
||||
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noSubId')}</div>
|
||||
)}
|
||||
{client?.subId && !hasAnything && !loading && (
|
||||
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noLinks')}</div>
|
||||
)}
|
||||
{hasAnything && (
|
||||
<Collapse
|
||||
activeKey={activeKey}
|
||||
onChange={(keys) => setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import QrPanel from '@/pages/inbounds/QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
client: { type: Object, default: null },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const subLink = computed(() => {
|
||||
if (!props.client?.subId || !props.subSettings?.enable || !props.subSettings?.subURI) return '';
|
||||
return props.subSettings.subURI + props.client.subId;
|
||||
});
|
||||
|
||||
const subJsonLink = computed(() => {
|
||||
if (!props.client?.subId || !props.subSettings?.enable) return '';
|
||||
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
|
||||
return props.subSettings.subJsonURI + props.client.subId;
|
||||
});
|
||||
|
||||
const activeKeys = computed(() => {
|
||||
const keys = [];
|
||||
if (subLink.value) keys.push('sub');
|
||||
if (subJsonLink.value) keys.push('subJson');
|
||||
if (links.value.length > 0) keys.push('l0');
|
||||
return keys;
|
||||
});
|
||||
|
||||
const hasAnything = computed(
|
||||
() => !!subLink.value || !!subJsonLink.value || links.value.length > 0,
|
||||
);
|
||||
|
||||
watch(() => props.open, async (next) => {
|
||||
if (!next || !props.client?.subId) {
|
||||
links.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
|
||||
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
|
||||
@cancel="close">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!client?.subId && !loading" class="empty">
|
||||
{{ t('pages.clients.noSubId') }}
|
||||
</div>
|
||||
<div v-else-if="!hasAnything && !loading" class="empty">
|
||||
{{ t('pages.clients.noLinks') }}
|
||||
</div>
|
||||
<a-collapse v-else :active-key="activeKeys" accordion>
|
||||
<a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
|
||||
<QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
|
||||
<QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
|
||||
:header="`${t('pages.clients.link')} ${idx + 1}`">
|
||||
<QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile {
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
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); }
|
||||
|
||||
.status-tag {
|
||||
margin: 0 0 0 4px;
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.email-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.email-cell .email {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.email-cell .sub {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.client-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 4px 8px;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 12px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-head .tag-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.row-action-trigger:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
opacity: 0.55;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clients-empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Popover,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FilterOutlined,
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
QrcodeOutlined,
|
||||
RestOutlined,
|
||||
RetweetOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
UsergroupAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
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, ObjectUtil, SizeFormatter } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import ClientFormModal from './ClientFormModal';
|
||||
import ClientInfoModal from './ClientInfoModal';
|
||||
import ClientQrModal from './ClientQrModal';
|
||||
import ClientBulkAddModal from './ClientBulkAddModal';
|
||||
import '@/styles/page-cards.css';
|
||||
import './ClientsPage.css';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||
|
||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||
|
||||
interface FilterState {
|
||||
enableFilter: boolean;
|
||||
searchKey: string;
|
||||
filterBy: string;
|
||||
protocolFilter?: string;
|
||||
}
|
||||
|
||||
function readFilterState(): FilterState {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||||
return {
|
||||
enableFilter: !!raw.enableFilter,
|
||||
searchKey: raw.searchKey || '',
|
||||
filterBy: raw.filterBy || '',
|
||||
protocolFilter: raw.protocolFilter,
|
||||
};
|
||||
} catch {
|
||||
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const {
|
||||
clients, inbounds, onlines, loading, fetched, subSettings,
|
||||
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||
create, update, remove, removeMany, attach, detach,
|
||||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||||
applyTrafficEvent, applyClientStatsEvent, applyInvalidate,
|
||||
} = useClients();
|
||||
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
});
|
||||
|
||||
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
||||
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
const initial = readFilterState();
|
||||
const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
|
||||
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
||||
const [filterBy, setFilterBy] = useState(initial.filterBy);
|
||||
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
|
||||
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [tablePageSize, setTablePageSize] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||
enableFilter, searchKey, filterBy, protocolFilter,
|
||||
}));
|
||||
}, [enableFilter, searchKey, filterBy, protocolFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageSize > 0) {
|
||||
|
||||
setTablePageSize(pageSize);
|
||||
}
|
||||
}, [pageSize]);
|
||||
|
||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||
const inboundsById = useMemo(() => {
|
||||
const out: Record<number, InboundOption> = {};
|
||||
for (const ib of inbounds) out[ib.id] = ib;
|
||||
return out;
|
||||
}, [inbounds]);
|
||||
|
||||
const protocolOptions = useMemo(() => {
|
||||
const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
|
||||
return [...values].sort();
|
||||
}, [inbounds]);
|
||||
|
||||
const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
|
||||
|
||||
function inboundLabel(id: number) {
|
||||
const ib = inboundsById[id];
|
||||
if (!ib) return `#${id}`;
|
||||
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
|
||||
}
|
||||
|
||||
const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
|
||||
if (!row) return null;
|
||||
const traffic = row.traffic || {};
|
||||
const used = (traffic.up || 0) + (traffic.down || 0);
|
||||
const total = row.totalGB || 0;
|
||||
const now = Date.now();
|
||||
const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
|
||||
const exhausted = total > 0 && used >= total;
|
||||
if (expired || exhausted) return 'depleted';
|
||||
if (!row.enable) return 'deactive';
|
||||
const nearExpiry = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
|
||||
const nearLimit = total > 0 && total - used < (trafficDiff || 0);
|
||||
if (nearExpiry || nearLimit) return 'expiring';
|
||||
return 'active';
|
||||
}, [expireDiff, trafficDiff]);
|
||||
|
||||
function bucketBadgeColor(bucket: Bucket | null): string {
|
||||
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)';
|
||||
}
|
||||
}
|
||||
|
||||
function clientMatchesProtocol(row: ClientRecord, protocol?: string) {
|
||||
if (!protocol) return true;
|
||||
const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
|
||||
for (const id of ids) {
|
||||
const ib = inboundsById[id];
|
||||
if (ib && ib.protocol === protocol) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
let rows = clients || [];
|
||||
if (enableFilter) {
|
||||
if (filterBy === 'online') {
|
||||
rows = rows.filter((r) => r.enable && isOnline(r.email));
|
||||
} else if (filterBy) {
|
||||
rows = rows.filter((r) => clientBucket(r) === filterBy);
|
||||
}
|
||||
} else if (!ObjectUtil.isEmpty(searchKey)) {
|
||||
rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey));
|
||||
}
|
||||
if (protocolFilter) {
|
||||
rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter));
|
||||
}
|
||||
return rows;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clients, enableFilter, filterBy, searchKey, protocolFilter, clientBucket]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const rows = clients || [];
|
||||
const deactive: string[] = [];
|
||||
const depleted: string[] = [];
|
||||
const expiring: string[] = [];
|
||||
const online: string[] = [];
|
||||
let active = 0;
|
||||
for (const row of rows) {
|
||||
const bucket = clientBucket(row);
|
||||
if (bucket === 'deactive') deactive.push(row.email);
|
||||
else if (bucket === 'depleted') depleted.push(row.email);
|
||||
else if (bucket === 'expiring') expiring.push(row.email);
|
||||
else if (bucket === 'active') active++;
|
||||
if (row.enable && isOnline(row.email)) online.push(row.email);
|
||||
}
|
||||
return { total: rows.length, active, deactive, depleted, expiring, online };
|
||||
}, [clients, clientBucket, isOnline]);
|
||||
|
||||
const sortFns: Record<string, (a: ClientRecord, b: ClientRecord) => number> = {
|
||||
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
||||
email: (a, b) => (a.email || '').localeCompare(b.email || ''),
|
||||
inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0),
|
||||
traffic: (a, b) => {
|
||||
const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0);
|
||||
const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0);
|
||||
return ua - ub;
|
||||
},
|
||||
remaining: (a, b) => {
|
||||
const ra = (a.totalGB || 0) > 0 ? (a.totalGB || 0) - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity;
|
||||
const rb = (b.totalGB || 0) > 0 ? (b.totalGB || 0) - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity;
|
||||
return ra - rb;
|
||||
},
|
||||
expiryTime: (a, b) => {
|
||||
const ea = (a.expiryTime ?? 0) > 0 ? (a.expiryTime ?? 0) : Infinity;
|
||||
const eb = (b.expiryTime ?? 0) > 0 ? (b.expiryTime ?? 0) : Infinity;
|
||||
return ea - eb;
|
||||
},
|
||||
};
|
||||
|
||||
const sortedClients = useMemo(() => {
|
||||
if (!sortColumn || !sortOrder) return filteredClients;
|
||||
const fn = sortFns[sortColumn];
|
||||
if (!fn) return filteredClients;
|
||||
const sorted = [...filteredClients].sort(fn);
|
||||
return sortOrder === 'descend' ? sorted.reverse() : sorted;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filteredClients, sortColumn, sortOrder]);
|
||||
|
||||
function trafficLabel(row: ClientRecord) {
|
||||
const t0 = row.traffic;
|
||||
if (!t0) return '-';
|
||||
const used = (t0.up || 0) + (t0.down || 0);
|
||||
const total = row.totalGB || 0;
|
||||
if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
|
||||
return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
|
||||
}
|
||||
|
||||
function remainingLabel(row: ClientRecord) {
|
||||
const total = row.totalGB || 0;
|
||||
if (total <= 0) return '∞';
|
||||
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
|
||||
const r = total - used;
|
||||
return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
|
||||
}
|
||||
|
||||
function remainingColor(row: ClientRecord): string {
|
||||
const total = row.totalGB || 0;
|
||||
if (total <= 0) return 'purple';
|
||||
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
|
||||
const ratio = used / total;
|
||||
if (ratio >= 1) return 'red';
|
||||
if (ratio >= 0.85) return 'orange';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
function expiryLabel(row: ClientRecord) {
|
||||
if (!row.expiryTime) return '∞';
|
||||
if (row.expiryTime < 0) {
|
||||
const days = Math.round(row.expiryTime / -86400000);
|
||||
return `${t('pages.clients.delayedStart')}: ${days}d`;
|
||||
}
|
||||
return IntlUtil.formatDate(row.expiryTime, datepicker);
|
||||
}
|
||||
|
||||
function expiryRelative(row: ClientRecord) {
|
||||
if (!row.expiryTime) return '';
|
||||
if (row.expiryTime < 0) {
|
||||
const days = Math.round(row.expiryTime / -86400000);
|
||||
return `${days}d`;
|
||||
}
|
||||
return IntlUtil.formatRelativeTime(row.expiryTime);
|
||||
}
|
||||
|
||||
function expiryColor(row: ClientRecord): string {
|
||||
if (!row.expiryTime) return 'purple';
|
||||
if (row.expiryTime < 0) return 'blue';
|
||||
const now = Date.now();
|
||||
if (row.expiryTime <= now) return 'red';
|
||||
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
async function onToggleEnable(row: ClientRecord, next: boolean) {
|
||||
setTogglingEmail(row.email);
|
||||
try {
|
||||
const msg = await setEnable(row, next);
|
||||
if (!msg?.success) {
|
||||
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
}
|
||||
} finally {
|
||||
setTogglingEmail(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
setFormMode('add');
|
||||
setEditingClient(null);
|
||||
setEditingAttachedIds([]);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function onEdit(row: ClientRecord) {
|
||||
setFormMode('edit');
|
||||
setEditingClient({ ...row });
|
||||
setEditingAttachedIds(Array.isArray(row.inboundIds) ? [...row.inboundIds] : []);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function onDelete(row: ClientRecord) {
|
||||
modal.confirm({
|
||||
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
|
||||
content: t('pages.clients.deleteConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(row.email);
|
||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onResetTraffic(row: ClientRecord) {
|
||||
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
|
||||
messageApi.warning(t('pages.clients.resetNotPossible'));
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
|
||||
content: t('pages.inbounds.resetTrafficContent'),
|
||||
okText: t('reset'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await resetTraffic(row);
|
||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onShowInfo(row: ClientRecord) {
|
||||
setInfoClient(row);
|
||||
setInfoOpen(true);
|
||||
}
|
||||
|
||||
function onShowQr(row: ClientRecord) {
|
||||
setQrClient(row);
|
||||
setQrOpen(true);
|
||||
}
|
||||
|
||||
function onResetAllTraffics() {
|
||||
modal.confirm({
|
||||
title: t('pages.clients.resetAllTrafficsTitle'),
|
||||
content: t('pages.clients.resetAllTrafficsContent'),
|
||||
okText: t('reset'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await resetAllTraffics();
|
||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onDelDepleted() {
|
||||
modal.confirm({
|
||||
title: t('pages.clients.delDepletedConfirmTitle'),
|
||||
content: t('pages.clients.delDepletedConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await delDepleted();
|
||||
if (msg?.success) {
|
||||
const deleted = msg.obj?.deleted ?? 0;
|
||||
messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onBulkDelete() {
|
||||
const emails = [...selectedRowKeys];
|
||||
if (emails.length === 0) return;
|
||||
modal.confirm({
|
||||
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
|
||||
content: t('pages.clients.bulkDeleteConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const results = await removeMany(emails);
|
||||
setSelectedRowKeys([]);
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
let firstError = '';
|
||||
for (const msg of results) {
|
||||
if (msg?.success) ok++;
|
||||
else {
|
||||
failed++;
|
||||
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
|
||||
} else {
|
||||
messageApi.warning(firstError
|
||||
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
|
||||
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onSave = useCallback(async (
|
||||
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
|
||||
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
|
||||
) => {
|
||||
if (!meta.isEdit) {
|
||||
return create(payload);
|
||||
}
|
||||
const updateMsg = await update(meta.email, payload);
|
||||
if (!updateMsg?.success) return updateMsg;
|
||||
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
|
||||
const r = await attach(meta.email, meta.attach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
|
||||
const r = await detach(meta.email, meta.detach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
return updateMsg;
|
||||
}, [create, update, attach, detach]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['clients-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
|
||||
if (pag?.current) setCurrentPage(pag.current);
|
||||
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
|
||||
setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnsType<ClientRecord>>(() => {
|
||||
function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
|
||||
return {
|
||||
...col,
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
sortOrder: sortColumn === key ? sortOrder : null,
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
};
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: t('pages.clients.actions'),
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_v, record) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.clients.moreInformation')}>
|
||||
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
sortableCol({
|
||||
title: t('pages.clients.enabled'), key: 'enable', width: 80,
|
||||
render: (_v, record) => (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
loading={togglingEmail === record.email}
|
||||
onChange={(next) => onToggleEnable(record, next)}
|
||||
/>
|
||||
),
|
||||
}, 'enable'),
|
||||
{
|
||||
title: t('pages.clients.online'),
|
||||
key: 'online',
|
||||
width: 90,
|
||||
render: (_v, record) => {
|
||||
const bucket = clientBucket(record);
|
||||
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
|
||||
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
|
||||
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
||||
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
||||
return <Tag>{t('pages.clients.offline')}</Tag>;
|
||||
},
|
||||
},
|
||||
sortableCol({
|
||||
title: t('pages.clients.client'),
|
||||
key: 'email',
|
||||
render: (_v, record) => (
|
||||
<div className="email-cell">
|
||||
<span className="email">{record.email}</span>
|
||||
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
|
||||
</div>
|
||||
),
|
||||
}, 'email'),
|
||||
sortableCol({
|
||||
title: t('pages.clients.attachedInbounds'),
|
||||
key: 'inboundIds',
|
||||
render: (_v, record) => {
|
||||
const ids = record.inboundIds || [];
|
||||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||||
return ids.map((id) => (
|
||||
<Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
|
||||
));
|
||||
},
|
||||
}, 'inboundIds'),
|
||||
sortableCol({
|
||||
title: t('pages.clients.traffic'),
|
||||
key: 'traffic',
|
||||
render: (_v, record) => trafficLabel(record),
|
||||
}, 'traffic'),
|
||||
sortableCol({
|
||||
title: t('pages.clients.remaining'),
|
||||
key: 'remaining',
|
||||
width: 130,
|
||||
render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
|
||||
}, 'remaining'),
|
||||
sortableCol({
|
||||
title: t('pages.clients.duration'),
|
||||
key: 'expiryTime',
|
||||
render: (_v, record) => (
|
||||
<Tooltip title={expiryLabel(record)}>
|
||||
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
}, 'expiryTime'),
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline]);
|
||||
|
||||
const tablePagination = {
|
||||
current: currentPage,
|
||||
pageSize: tablePageSize,
|
||||
total: sortedClients.length,
|
||||
showSizeChanger: sortedClients.length > 10,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
hideOnSinglePage: sortedClients.length <= tablePageSize,
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
|
||||
};
|
||||
|
||||
function toggleSelect(email: string, checked: boolean) {
|
||||
setSelectedRowKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(email); else next.delete(email);
|
||||
return Array.from(next);
|
||||
});
|
||||
}
|
||||
|
||||
function selectAll(checked: boolean) {
|
||||
setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
|
||||
}
|
||||
|
||||
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
|
||||
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||
|
||||
function onToggleFilter(checked: boolean) {
|
||||
setEnableFilter(checked);
|
||||
if (checked) setSearchKey('');
|
||||
else setFilterBy('');
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||
<Col span={24}>
|
||||
<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 />} />
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('online')}
|
||||
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" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('depleted')}
|
||||
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" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('depletingSoon')}
|
||||
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" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('disabled')}
|
||||
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" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<CustomStatistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
title={
|
||||
<div className="card-toolbar">
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{!isMobile && t('pages.clients.addClients')}
|
||||
</Button>
|
||||
<Button size="small" icon={<UsergroupAddOutlined />} onClick={() => setBulkAddOpen(true)}>
|
||||
{!isMobile && t('pages.clients.bulk')}
|
||||
</Button>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
|
||||
{t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" icon={<RetweetOutlined />} onClick={onResetAllTraffics}>
|
||||
{!isMobile && t('pages.clients.resetAllTraffics')}
|
||||
</Button>
|
||||
<Button size="small" danger icon={<RestOutlined />} onClick={onDelDepleted}>
|
||||
{!isMobile && t('pages.clients.delDepleted')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
|
||||
<Switch
|
||||
checked={enableFilter}
|
||||
onChange={onToggleFilter}
|
||||
checkedChildren={<SearchOutlined />}
|
||||
unCheckedChildren={<FilterOutlined />}
|
||||
/>
|
||||
{!enableFilter && (
|
||||
<Input
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder={t('search')}
|
||||
autoFocus
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ maxWidth: 300 }}
|
||||
/>
|
||||
)}
|
||||
{enableFilter && (
|
||||
<Radio.Group
|
||||
value={filterBy}
|
||||
onChange={(e) => setFilterBy(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
<Radio.Button value="">{t('none')}</Radio.Button>
|
||||
<Radio.Button value="active">{t('subscription.active')}</Radio.Button>
|
||||
<Radio.Button value="deactive">{t('disabled')}</Radio.Button>
|
||||
<Radio.Button value="depleted">{t('depleted')}</Radio.Button>
|
||||
<Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
|
||||
<Radio.Button value="online">{t('online')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
)}
|
||||
<Select
|
||||
value={protocolFilter}
|
||||
onChange={(v) => setProtocolFilter(v)}
|
||||
allowClear
|
||||
placeholder={t('pages.inbounds.protocol')}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ width: 150 }}
|
||||
options={protocolOptions.map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isMobile ? (
|
||||
<Table<ClientRecord>
|
||||
columns={columns}
|
||||
dataSource={sortedClients}
|
||||
loading={loading}
|
||||
rowKey="email"
|
||||
rowSelection={rowSelection}
|
||||
pagination={tablePagination}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
onChange={onTableChange}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="clients-empty">
|
||||
<UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('pages.clients.empty')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<div className="client-cards">
|
||||
{filteredClients.length > 0 && (
|
||||
<div className="card-bulk-bar">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={(e) => selectAll(e.target.checked)}
|
||||
>
|
||||
{t('pages.clients.selectAll')}
|
||||
</Checkbox>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<span className="bulk-count">{selectedRowKeys.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filteredClients.length === 0 && (
|
||||
<div className="card-empty">
|
||||
<UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||||
<div>{t('pages.clients.empty')}</div>
|
||||
</div>
|
||||
)}
|
||||
{filteredClients.map((row) => {
|
||||
const bucket = clientBucket(row);
|
||||
return (
|
||||
<div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
|
||||
<div className="card-head">
|
||||
<Checkbox
|
||||
checked={selectedRowKeys.includes(row.email)}
|
||||
onChange={(e) => toggleSelect(row.email, e.target.checked)}
|
||||
/>
|
||||
<Badge color={bucketBadgeColor(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>}
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={t('pages.clients.moreInformation')}>
|
||||
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!row.enable}
|
||||
size="small"
|
||||
loading={togglingEmail === row.email}
|
||||
onChange={(next) => onToggleEnable(row, next)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'qr',
|
||||
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
||||
onClick: () => onShowQr(row),
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
||||
onClick: () => onResetTraffic(row),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: <><EditOutlined /> {t('edit')}</>,
|
||||
onClick: () => onEdit(row),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: <><DeleteOutlined /> {t('delete')}</>,
|
||||
onClick: () => onDelete(row),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<ClientFormModal
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
client={editingClient}
|
||||
attachedIds={editingAttachedIds}
|
||||
inbounds={inbounds}
|
||||
ipLimitEnable={ipLimitEnable}
|
||||
tgBotEnable={tgBotEnable}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
<ClientInfoModal
|
||||
open={infoOpen}
|
||||
client={infoClient}
|
||||
inboundsById={inboundsById}
|
||||
isOnline={infoClient ? isOnline(infoClient.email) : false}
|
||||
subSettings={subSettings}
|
||||
onOpenChange={setInfoOpen}
|
||||
/>
|
||||
<ClientQrModal
|
||||
open={qrOpen}
|
||||
client={qrClient}
|
||||
subSettings={subSettings}
|
||||
onOpenChange={setQrOpen}
|
||||
/>
|
||||
<ClientBulkAddModal
|
||||
open={bulkAddOpen}
|
||||
inbounds={inbounds}
|
||||
ipLimitEnable={ipLimitEnable}
|
||||
onOpenChange={setBulkAddOpen}
|
||||
onSaved={() => setBulkAddOpen(false)}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,220 +0,0 @@
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
export function useClients() {
|
||||
const clients = shallowRef([]);
|
||||
const inbounds = shallowRef([]);
|
||||
const onlines = ref([]);
|
||||
const loading = ref(false);
|
||||
const fetched = ref(false);
|
||||
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
|
||||
const ipLimitEnable = ref(false);
|
||||
const tgBotEnable = ref(false);
|
||||
const expireDiff = ref(0);
|
||||
const trafficDiff = ref(0);
|
||||
const pageSize = ref(0);
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [clientsMsg, inboundsMsg] = await Promise.all([
|
||||
HttpUtil.get('/panel/api/clients/list'),
|
||||
HttpUtil.get('/panel/api/inbounds/options'),
|
||||
]);
|
||||
if (clientsMsg?.success) {
|
||||
clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
|
||||
}
|
||||
if (inboundsMsg?.success) {
|
||||
inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSubSettings() {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
subSettings.value = {
|
||||
enable: !!s.subEnable,
|
||||
subURI: s.subURI || '',
|
||||
subJsonURI: s.subJsonURI || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
};
|
||||
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||
tgBotEnable.value = !!s.tgBotEnable;
|
||||
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||
pageSize.value = s.pageSize ?? 0;
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function update(email, client) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function remove(email, keepTraffic = false) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encoded}`;
|
||||
const msg = await HttpUtil.post(url);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function removeMany(emails, keepTraffic = false) {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||
const silentOpts = { silent: true };
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||
return HttpUtil.post(url, undefined, silentOpts);
|
||||
}));
|
||||
await refresh();
|
||||
return results;
|
||||
}
|
||||
|
||||
async function attach(email, inboundIds) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function detach(email, inboundIds) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function resetTraffic(client) {
|
||||
if (!client?.email) return null;
|
||||
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||
const msg = await HttpUtil.post(url);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function resetAllTraffics() {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics');
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function delDepleted() {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function setEnable(client, enable) {
|
||||
if (!client?.email) return null;
|
||||
const payload = {
|
||||
email: client.email,
|
||||
subId: client.subId,
|
||||
id: client.uuid,
|
||||
password: client.password,
|
||||
auth: client.auth,
|
||||
totalGB: client.totalGB || 0,
|
||||
expiryTime: client.expiryTime || 0,
|
||||
limitIp: client.limitIp || 0,
|
||||
comment: client.comment || '',
|
||||
enable: !!enable,
|
||||
};
|
||||
return update(client.email, payload);
|
||||
}
|
||||
|
||||
function applyTrafficEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (Array.isArray(payload.onlineClients)) {
|
||||
onlines.value = payload.onlineClients;
|
||||
}
|
||||
}
|
||||
|
||||
function applyClientStatsEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (!Array.isArray(payload.clients) || payload.clients.length === 0) return;
|
||||
const byEmail = new Map();
|
||||
for (const row of payload.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
let touched = false;
|
||||
const next = clients.value || [];
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (touched) clients.value = [...next];
|
||||
}
|
||||
|
||||
let invalidateTimer = null;
|
||||
function applyInvalidate(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
refresh();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([refresh(), fetchSubSettings()]);
|
||||
});
|
||||
|
||||
return {
|
||||
clients,
|
||||
inbounds,
|
||||
onlines,
|
||||
loading,
|
||||
fetched,
|
||||
subSettings,
|
||||
ipLimitEnable,
|
||||
tgBotEnable,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeMany,
|
||||
attach,
|
||||
detach,
|
||||
resetTraffic,
|
||||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.wg-peer {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.advanced-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.advanced-panel {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
}
|
||||
|
||||
.advanced-panel__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.advanced-panel__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.advanced-panel__subtitle {
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.advanced-inner-tabs .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.advanced-inner-tabs .ant-tabs-tab {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.advanced-editor-meta {
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.75;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.advanced-panel {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.advanced-inner-tabs .ant-tabs-tab {
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table.block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-table td,
|
||||
.info-table th {
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-large-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-list-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-sep {
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-row dt {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.info-row dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value-tag {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
display: inline-block;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.value-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value-code {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
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;
|
||||
}
|
||||
|
||||
.share-buttons {
|
||||
margin-inline-start: 4px;
|
||||
padding-inline-start: 8px;
|
||||
border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
|
||||
.summary-table {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ip-log {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ip-log-row {
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ip-log-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.protocol-table {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wg-table td {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.link-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link-panel-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
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);
|
||||
border-radius: 4px;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { getMessage } from '@/utils/messageBus';
|
||||
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
HttpUtil,
|
||||
IntlUtil,
|
||||
SizeFormatter,
|
||||
ColorUtils,
|
||||
ClipboardManager,
|
||||
FileManager,
|
||||
} from '@/utils';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import InfinityIcon from '@/components/InfinityIcon';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { SubSettings } from './useInbounds';
|
||||
import './InboundInfoModal.css';
|
||||
|
||||
interface ClientStats {
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
interface ClientSetting {
|
||||
email?: string;
|
||||
id?: string;
|
||||
security?: string;
|
||||
password?: string;
|
||||
flow?: string;
|
||||
subId?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
comment?: string;
|
||||
tgId?: string;
|
||||
enable?: boolean;
|
||||
limitIp?: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
interface InboundLike {
|
||||
protocol: string;
|
||||
clients?: ClientSetting[];
|
||||
settings?: Record<string, unknown>;
|
||||
serverName?: string;
|
||||
isTcp?: boolean;
|
||||
isWs?: boolean;
|
||||
isHttpupgrade?: boolean;
|
||||
isXHTTP?: boolean;
|
||||
isGrpc?: boolean;
|
||||
isSSMultiUser?: boolean;
|
||||
isSS2022?: boolean;
|
||||
host?: string;
|
||||
path?: string;
|
||||
serviceName?: string;
|
||||
stream?: {
|
||||
network?: string;
|
||||
security?: string;
|
||||
xhttp?: { mode?: string };
|
||||
grpc?: { multiMode?: boolean };
|
||||
};
|
||||
canEnableTlsFlow?: () => boolean;
|
||||
genWireguardConfigs: (remark: string, model: string, host: string) => string;
|
||||
genWireguardLinks: (remark: string, model: string, host: string) => string;
|
||||
genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[];
|
||||
}
|
||||
|
||||
interface DBInboundLike {
|
||||
id: number;
|
||||
address: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
remark: string;
|
||||
enable?: boolean;
|
||||
isVMess?: boolean;
|
||||
isVLess?: boolean;
|
||||
isTrojan?: boolean;
|
||||
isSS?: boolean;
|
||||
isMixed?: boolean;
|
||||
isHTTP?: boolean;
|
||||
isWireguard?: boolean;
|
||||
clientStats?: ClientStats[];
|
||||
hasLink: () => boolean;
|
||||
toInbound: () => InboundLike;
|
||||
}
|
||||
|
||||
interface InboundInfoModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dbInbound: DBInboundLike | null;
|
||||
clientIndex?: number;
|
||||
remarkModel?: string;
|
||||
expireDiff?: number;
|
||||
trafficDiff?: number;
|
||||
ipLimitEnable?: boolean;
|
||||
tgBotEnable?: boolean;
|
||||
nodeAddress?: string;
|
||||
subSettings?: SubSettings;
|
||||
lastOnlineMap?: Record<string, number>;
|
||||
}
|
||||
|
||||
function copyText(value: unknown, t: (k: string) => string) {
|
||||
ClipboardManager.copyText(String(value ?? '')).then((ok) => {
|
||||
if (ok) getMessage().success(t('copied'));
|
||||
});
|
||||
}
|
||||
|
||||
function downloadText(content: string, filename: string) {
|
||||
FileManager.downloadTextFile(content, filename);
|
||||
}
|
||||
|
||||
function statsColor(stats: ClientStats, trafficDiff: number) {
|
||||
return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
|
||||
}
|
||||
|
||||
function formatIpInfo(record: unknown) {
|
||||
if (record == null) return '';
|
||||
if (typeof record === 'string' || typeof record === 'number') return String(record);
|
||||
const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
|
||||
const ip = r.ip || r.IP || '';
|
||||
const ts = r.timestamp || r.Timestamp || 0;
|
||||
if (!ip) return String(record);
|
||||
if (!ts) return String(ip);
|
||||
const date = new Date(Number(ts) * 1000);
|
||||
const timeStr = date
|
||||
.toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.replace(',', '');
|
||||
return `${ip} (${timeStr})`;
|
||||
}
|
||||
|
||||
export default function InboundInfoModal({
|
||||
open,
|
||||
onClose,
|
||||
dbInbound,
|
||||
clientIndex = 0,
|
||||
remarkModel = '-ieo',
|
||||
expireDiff = 0,
|
||||
trafficDiff = 0,
|
||||
ipLimitEnable = false,
|
||||
tgBotEnable = false,
|
||||
nodeAddress = '',
|
||||
subSettings,
|
||||
lastOnlineMap = {},
|
||||
}: InboundInfoModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const [inbound, setInbound] = useState<InboundLike | null>(null);
|
||||
const [clientSettings, setClientSettings] = useState<ClientSetting | null>(null);
|
||||
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
|
||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||
const [subLink, setSubLink] = useState('');
|
||||
const [subJsonLink, setSubJsonLink] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
|
||||
const [clientIpsText, setClientIpsText] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('client');
|
||||
|
||||
const loadClientIps = useCallback(async () => {
|
||||
if (!clientStats?.email) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`);
|
||||
if (!msg?.success) {
|
||||
setClientIpsText((msg?.obj as string) || 'No IP record');
|
||||
setClientIpsArray([]);
|
||||
return;
|
||||
}
|
||||
let ips: unknown = msg.obj;
|
||||
if (typeof ips === 'string') {
|
||||
try {
|
||||
ips = JSON.parse(ips);
|
||||
} catch {
|
||||
setClientIpsText(String(ips));
|
||||
setClientIpsArray([String(ips)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips];
|
||||
if (Array.isArray(ips) && ips.length > 0) {
|
||||
const arr = (ips as unknown[]).map(formatIpInfo).filter(Boolean) as string[];
|
||||
setClientIpsArray(arr);
|
||||
setClientIpsText(arr.join(' | '));
|
||||
} else {
|
||||
setClientIpsArray([]);
|
||||
setClientIpsText(String(ips || t('tgbot.noIpRecord')));
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [clientStats, t]);
|
||||
|
||||
const clearClientIps = useCallback(async () => {
|
||||
if (!clientStats?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`);
|
||||
if (msg?.success) {
|
||||
setClientIpsArray([]);
|
||||
setClientIpsText(t('tgbot.noIpRecord'));
|
||||
}
|
||||
}, [clientStats, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !dbInbound) return;
|
||||
const parsed = dbInbound.toInbound();
|
||||
setInbound(parsed);
|
||||
setActiveTab((parsed.clients?.length ?? 0) > 0 ? 'client' : 'inbound');
|
||||
|
||||
const idx = clientIndex ?? 0;
|
||||
const clientSet = (parsed.clients?.length ?? 0) > 0 ? (parsed.clients?.[idx] || null) : null;
|
||||
setClientSettings(clientSet);
|
||||
const stats = clientSet
|
||||
? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null
|
||||
: null;
|
||||
setClientStats(stats);
|
||||
|
||||
if (parsed.protocol === Protocols.WIREGUARD) {
|
||||
setWireguardConfigs(parsed.genWireguardConfigs(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
|
||||
setWireguardLinks(parsed.genWireguardLinks(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
|
||||
setLinks([]);
|
||||
} else {
|
||||
setLinks(parsed.genAllLinks(dbInbound.remark, remarkModel, clientSet, nodeAddress));
|
||||
setWireguardConfigs([]);
|
||||
setWireguardLinks([]);
|
||||
}
|
||||
|
||||
if (clientSet?.subId) {
|
||||
setSubLink((subSettings?.subURI || '') + clientSet.subId);
|
||||
setSubJsonLink(
|
||||
subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '',
|
||||
);
|
||||
} else {
|
||||
setSubLink('');
|
||||
setSubJsonLink('');
|
||||
}
|
||||
|
||||
setClientIpsArray([]);
|
||||
setClientIpsText('');
|
||||
|
||||
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) {
|
||||
void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => {
|
||||
if (!msg?.success) {
|
||||
setClientIpsText((msg?.obj as string) || 'No IP record');
|
||||
return;
|
||||
}
|
||||
let ips: unknown = msg.obj;
|
||||
if (typeof ips === 'string') {
|
||||
try {
|
||||
ips = JSON.parse(ips);
|
||||
} catch {
|
||||
setClientIpsText(String(ips));
|
||||
setClientIpsArray([String(ips)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips];
|
||||
if (Array.isArray(ips) && ips.length > 0) {
|
||||
const arr = (ips as unknown[]).map(formatIpInfo).filter(Boolean) as string[];
|
||||
setClientIpsArray(arr);
|
||||
setClientIpsText(arr.join(' | '));
|
||||
} else {
|
||||
setClientIpsText(String(ips || t('tgbot.noIpRecord')));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]);
|
||||
|
||||
const isEnable = useMemo(() => {
|
||||
if (clientSettings) return !!clientSettings.enable;
|
||||
return dbInbound?.enable ?? true;
|
||||
}, [clientSettings, dbInbound]);
|
||||
|
||||
const isDepleted = useMemo(() => {
|
||||
if (!clientStats || !clientSettings) return false;
|
||||
const total = clientStats.total ?? 0;
|
||||
const used = (clientStats.up ?? 0) + (clientStats.down ?? 0);
|
||||
if (total > 0 && used >= total) return true;
|
||||
const expiry = clientSettings.expiryTime ?? 0;
|
||||
if (expiry > 0 && Date.now() >= expiry) return true;
|
||||
return false;
|
||||
}, [clientStats, clientSettings]);
|
||||
|
||||
const remainingStats = useMemo(() => {
|
||||
if (!clientStats || !clientSettings) return '-';
|
||||
const remained = clientStats.total - clientStats.up - clientStats.down;
|
||||
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
||||
}, [clientStats, clientSettings]);
|
||||
|
||||
const formatLastOnline = useCallback(
|
||||
(email: string) => {
|
||||
const ts = lastOnlineMap[email];
|
||||
if (!ts) return '-';
|
||||
return IntlUtil.formatDate(ts, datepicker);
|
||||
},
|
||||
[lastOnlineMap, datepicker],
|
||||
);
|
||||
|
||||
const networkLabel = inbound?.stream?.network || '';
|
||||
const securityLabel = inbound?.stream?.security || 'none';
|
||||
const securityColor = securityLabel === 'none' ? 'red' : 'green';
|
||||
const encryptionLabel = (inbound?.settings?.encryption as string) || '';
|
||||
const serverNameLabel = inbound?.serverName || '';
|
||||
const showClientTab = !!clientSettings;
|
||||
const showSubscriptionTab = !!(subSettings?.enable && clientSettings?.subId);
|
||||
|
||||
if (!dbInbound || !inbound) {
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} />
|
||||
);
|
||||
}
|
||||
|
||||
const clientTab = (
|
||||
<>
|
||||
<table className="info-table block">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.email')}</td>
|
||||
<td>
|
||||
{clientSettings?.email ? (
|
||||
<Tag color="green">{clientSettings.email}</Tag>
|
||||
) : (
|
||||
<Tag color="red">{t('none')}</Tag>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{clientSettings?.id && (
|
||||
<tr><td>ID</td><td><Tag>{clientSettings.id}</Tag></td></tr>
|
||||
)}
|
||||
{dbInbound.isVMess && (
|
||||
<tr><td>{t('security')}</td><td><Tag>{clientSettings?.security}</Tag></td></tr>
|
||||
)}
|
||||
{inbound.canEnableTlsFlow?.() && (
|
||||
<tr>
|
||||
<td>Flow</td>
|
||||
<td>
|
||||
{clientSettings?.flow ? <Tag>{clientSettings.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{clientSettings?.password && (
|
||||
<tr>
|
||||
<td>{t('password')}</td>
|
||||
<td><Tag className="info-large-tag">{clientSettings.password}</Tag></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('status')}</td>
|
||||
<td>
|
||||
{isDepleted ? (
|
||||
<Tag color="red">{t('depleted')}</Tag>
|
||||
) : isEnable ? (
|
||||
<Tag color="green">{t('enabled')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('disabled')}</Tag>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{clientStats && (
|
||||
<tr>
|
||||
<td>{t('usage')}</td>
|
||||
<td>
|
||||
<Tag color="green">{SizeFormatter.sizeFormat(clientStats.up + clientStats.down)}</Tag>
|
||||
<Tag>
|
||||
↑ {SizeFormatter.sizeFormat(clientStats.up)} /
|
||||
{' '}{SizeFormatter.sizeFormat(clientStats.down)} ↓
|
||||
</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.createdAt')}</td>
|
||||
<td>
|
||||
{clientSettings?.created_at ? (
|
||||
<Tag>{IntlUtil.formatDate(clientSettings.created_at, datepicker)}</Tag>
|
||||
) : <Tag>-</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.updatedAt')}</td>
|
||||
<td>
|
||||
{clientSettings?.updated_at ? (
|
||||
<Tag>{IntlUtil.formatDate(clientSettings.updated_at, datepicker)}</Tag>
|
||||
) : <Tag>-</Tag>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('lastOnline')}</td>
|
||||
<td><Tag>{formatLastOnline(clientSettings?.email || '')}</Tag></td>
|
||||
</tr>
|
||||
{clientSettings?.comment && (
|
||||
<tr><td>{t('comment')}</td><td><Tag className="info-large-tag">{clientSettings.comment}</Tag></td></tr>
|
||||
)}
|
||||
{ipLimitEnable && (
|
||||
<tr><td>{t('pages.inbounds.IPLimit')}</td><td><Tag>{clientSettings?.limitIp ?? 0}</Tag></td></tr>
|
||||
)}
|
||||
{ipLimitEnable && (clientSettings?.limitIp ?? 0) > 0 && (
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.IPLimitlog')}</td>
|
||||
<td>
|
||||
<div className="ip-log">
|
||||
{clientIpsArray.length > 0 ? (
|
||||
<div>
|
||||
{clientIpsArray.map((item, idx) => (
|
||||
<Tag color="blue" className="ip-log-row" key={idx}>{item}</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Tag>{clientIpsText || t('tgbot.noIpRecord')}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="ip-log-actions">
|
||||
<SyncOutlined spin={refreshing} onClick={() => loadClientIps()} />
|
||||
<Tooltip title={t('pages.inbounds.IPLimitlogclear')}>
|
||||
<DeleteOutlined onClick={() => clearClientIps()} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table className="info-table summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('remained')}</th>
|
||||
<th>{t('pages.inbounds.totalUsage')}</th>
|
||||
<th>{t('pages.inbounds.expireDate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{clientStats && (clientSettings?.totalGB ?? 0) > 0 ? (
|
||||
<Tag color={statsColor(clientStats, trafficDiff)}>{remainingStats}</Tag>
|
||||
) : !clientSettings?.totalGB || clientSettings.totalGB <= 0 ? (
|
||||
<Tag color="purple"><InfinityIcon /></Tag>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{(clientSettings?.totalGB ?? 0) > 0 ? (
|
||||
<Tag color={clientStats ? statsColor(clientStats, trafficDiff) : 'default'}>
|
||||
{SizeFormatter.sizeFormat(clientSettings!.totalGB!)}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="purple"><InfinityIcon /></Tag>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{(clientSettings?.expiryTime ?? 0) > 0 ? (
|
||||
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, clientSettings!.expiryTime!)}>
|
||||
{IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)}
|
||||
</Tag>
|
||||
) : (clientSettings?.expiryTime ?? 0) < 0 ? (
|
||||
<Tag color="green">{clientSettings!.expiryTime! / -86400000} {t('day')}</Tag>
|
||||
) : (
|
||||
<Tag color="purple"><InfinityIcon /></Tag>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{tgBotEnable && clientSettings?.tgId && (
|
||||
<>
|
||||
<Divider>Telegram</Divider>
|
||||
<div className="tg-row">
|
||||
<Tag color="blue">{clientSettings.tgId}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(clientSettings.tgId, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{dbInbound.hasLink() && links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => (
|
||||
<div key={idx} className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{link.link}</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSubscriptionTab && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{t('subscription.title')}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subLink, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
|
||||
</div>
|
||||
{subSettings?.subJsonEnable && subJsonLink && (
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">JSON</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subJsonLink, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const inboundTab = (
|
||||
<>
|
||||
<dl className="info-list">
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.protocol')}</dt>
|
||||
<dd><Tag color="purple">{dbInbound.protocol}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.address')}</dt>
|
||||
<dd><Tag className="value-tag">{dbInbound.address}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.port')}</dt>
|
||||
<dd><Tag>{dbInbound.port}</Tag></dd>
|
||||
</div>
|
||||
|
||||
{(dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS) && (
|
||||
<>
|
||||
<div className="info-row">
|
||||
<dt>{t('transmission')}</dt>
|
||||
<dd><Tag color="green">{networkLabel}</Tag></dd>
|
||||
</div>
|
||||
{(inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP) && (
|
||||
<>
|
||||
<div className="info-row">
|
||||
<dt>{t('host')}</dt>
|
||||
<dd>{inbound.host ? <Tag className="value-tag">{inbound.host}</Tag> : <Tag color="orange">{t('none')}</Tag>}</dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('path')}</dt>
|
||||
<dd>{inbound.path ? <Tag className="value-tag">{inbound.path}</Tag> : <Tag color="orange">{t('none')}</Tag>}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{inbound.isXHTTP && (
|
||||
<div className="info-row">
|
||||
<dt>Mode</dt>
|
||||
<dd><Tag>{inbound.stream?.xhttp?.mode}</Tag></dd>
|
||||
</div>
|
||||
)}
|
||||
{inbound.isGrpc && (
|
||||
<>
|
||||
<div className="info-row">
|
||||
<dt>grpc serviceName</dt>
|
||||
<dd><Tag className="value-tag">{inbound.serviceName}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>grpc multiMode</dt>
|
||||
<dd><Tag>{String(inbound.stream?.grpc?.multiMode)}</Tag></dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dbInbound.hasLink() && (
|
||||
<>
|
||||
<div className="info-row">
|
||||
<dt>{t('security')}</dt>
|
||||
<dd><Tag color={securityColor}>{securityLabel}</Tag></dd>
|
||||
</div>
|
||||
{encryptionLabel && (
|
||||
<div className="info-row">
|
||||
<dt>{t('encryption')}</dt>
|
||||
<dd className="value-block">
|
||||
<code className="value-code">{encryptionLabel}</code>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(encryptionLabel, t)} />
|
||||
</Tooltip>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{securityLabel !== 'none' && (
|
||||
<div className="info-row">
|
||||
<dt>{t('domainName')}</dt>
|
||||
<dd>
|
||||
{serverNameLabel ? (
|
||||
<Tag color="green" className="value-tag">{serverNameLabel}</Tag>
|
||||
) : (
|
||||
<Tag color="orange">{t('none')}</Tag>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{dbInbound.isSS && inbound.settings && (
|
||||
<table className="info-table block">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('encryption')}</td>
|
||||
<td><Tag color="green">{inbound.settings.method as string}</Tag></td>
|
||||
</tr>
|
||||
{inbound.isSS2022 && (
|
||||
<tr>
|
||||
<td>{t('password')}</td>
|
||||
<td><Tag className="info-large-tag">{inbound.settings.password as string}</Tag></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.network')}</td>
|
||||
<td><Tag color="green">{inbound.settings.network as string}</Tag></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{inbound.protocol === Protocols.TUN && inbound.settings && (
|
||||
<dl className="info-list info-list-block">
|
||||
<div className="info-row">
|
||||
<dt>Interface name</dt>
|
||||
<dd><Tag color="green" className="value-tag">{inbound.settings.name as string}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>MTU</dt>
|
||||
<dd><Tag color="green">{inbound.settings.mtu as number}</Tag></dd>
|
||||
</div>
|
||||
{Array.isArray(inbound.settings.gateway) && (inbound.settings.gateway as string[]).length > 0 && (
|
||||
<div className="info-row">
|
||||
<dt>Gateway</dt>
|
||||
<dd>
|
||||
{(inbound.settings.gateway as string[]).map((ip, j) => (
|
||||
<Tag key={`tun-gw-${j}`} color="green" className="value-tag">{ip}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(inbound.settings.dns) && (inbound.settings.dns as string[]).length > 0 && (
|
||||
<div className="info-row">
|
||||
<dt>DNS</dt>
|
||||
<dd>
|
||||
{(inbound.settings.dns as string[]).map((ip, j) => (
|
||||
<Tag key={`tun-dns-${j}`} color="green">{ip}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="info-row">
|
||||
<dt>Outbounds interface</dt>
|
||||
<dd><Tag color="green">{(inbound.settings.autoOutboundsInterface as string) || 'auto'}</Tag></dd>
|
||||
</div>
|
||||
{Array.isArray(inbound.settings.autoSystemRoutingTable) && (inbound.settings.autoSystemRoutingTable as string[]).length > 0 && (
|
||||
<div className="info-row">
|
||||
<dt>Auto system routes</dt>
|
||||
<dd>
|
||||
{(inbound.settings.autoSystemRoutingTable as string[]).map((cidr, j) => (
|
||||
<Tag key={`tun-rt-${j}`} color="green">{cidr}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{inbound.protocol === Protocols.TUNNEL && inbound.settings && (
|
||||
<dl className="info-list info-list-block">
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.targetAddress')}</dt>
|
||||
<dd><Tag color="green" className="value-tag">{inbound.settings.rewriteAddress as string}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.destinationPort')}</dt>
|
||||
<dd><Tag color="green">{inbound.settings.rewritePort as number}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.network')}</dt>
|
||||
<dd><Tag color="green">{inbound.settings.allowedNetwork as string}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>FollowRedirect</dt>
|
||||
<dd>
|
||||
<Tag color={inbound.settings.followRedirect ? 'green' : 'red'}>
|
||||
{inbound.settings.followRedirect ? t('enabled') : t('disabled')}
|
||||
</Tag>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{dbInbound.isMixed && inbound.settings && (
|
||||
<dl className="info-list info-list-block">
|
||||
<div className="info-row">
|
||||
<dt>Auth</dt>
|
||||
<dd>
|
||||
<Tag color={inbound.settings.auth === 'password' ? 'green' : 'orange'}>
|
||||
{inbound.settings.auth as string}
|
||||
</Tag>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>UDP</dt>
|
||||
<dd>
|
||||
<Tag color={inbound.settings.udp ? 'green' : 'red'}>
|
||||
{inbound.settings.udp ? t('enabled') : t('disabled')}
|
||||
</Tag>
|
||||
</dd>
|
||||
</div>
|
||||
{(inbound.settings.ip as string) && (
|
||||
<div className="info-row">
|
||||
<dt>IP</dt>
|
||||
<dd><Tag className="value-tag">{inbound.settings.ip as string}</Tag></dd>
|
||||
</div>
|
||||
)}
|
||||
{inbound.settings.auth === 'password' && Array.isArray(inbound.settings.accounts) && (
|
||||
<>
|
||||
{(inbound.settings.accounts as { user: string; pass: string }[]).map((account, idx) => (
|
||||
<div key={idx} className="info-row">
|
||||
<dt>{t('username')} #{idx + 1}</dt>
|
||||
<dd className="account-row">
|
||||
<Tag color="green" className="value-tag">{account.user}</Tag>
|
||||
<span className="account-sep">:</span>
|
||||
<Tag className="value-tag">{account.pass}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
|
||||
</Tooltip>
|
||||
<Space size={4} wrap className="share-buttons">
|
||||
<Tooltip title={`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
|
||||
<Button size="small" onClick={() => copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>SOCKS5</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
|
||||
<Button size="small" onClick={() => copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>HTTP</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
|
||||
<Button size="small" onClick={() => copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`, t)}>Telegram</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{inbound.settings.auth === 'noauth' && (
|
||||
<div className="info-row">
|
||||
<dt>{t('copy')}</dt>
|
||||
<dd>
|
||||
<Space size={4} wrap className="share-buttons">
|
||||
<Tooltip title={`socks5://${dbInbound.address}:${dbInbound.port}`}>
|
||||
<Button size="small" onClick={() => copyText(`socks5://${dbInbound.address}:${dbInbound.port}`, t)}>SOCKS5</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={`http://${dbInbound.address}:${dbInbound.port}`}>
|
||||
<Button size="small" onClick={() => copyText(`http://${dbInbound.address}:${dbInbound.port}`, t)}>HTTP</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="https://t.me/socks?server=...&port=...">
|
||||
<Button size="small" onClick={() => copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}`, t)}>Telegram</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{dbInbound.isHTTP && Array.isArray(inbound.settings?.accounts) && (inbound.settings!.accounts as unknown[]).length > 0 && (
|
||||
<dl className="info-list info-list-block">
|
||||
{(inbound.settings!.accounts as { user: string; pass: string }[]).map((account, idx) => (
|
||||
<div key={idx} className="info-row">
|
||||
<dt>{t('username')} #{idx + 1}</dt>
|
||||
<dd className="account-row">
|
||||
<Tag color="green" className="value-tag">{account.user}</Tag>
|
||||
<span className="account-sep">:</span>
|
||||
<Tag className="value-tag">{account.pass}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
|
||||
</Tooltip>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{dbInbound.isWireguard && inbound.settings && (
|
||||
<>
|
||||
<dl className="info-list info-list-block">
|
||||
<div className="info-row">
|
||||
<dt>Secret key</dt>
|
||||
<dd><Tag className="value-tag">{inbound.settings.secretKey as string}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>Public key</dt>
|
||||
<dd><Tag className="value-tag">{inbound.settings.pubKey as string}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>MTU</dt>
|
||||
<dd><Tag>{inbound.settings.mtu as number}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>No-kernel TUN</dt>
|
||||
<dd>
|
||||
<Tag color={inbound.settings.noKernelTun ? 'green' : 'default'}>
|
||||
{String(inbound.settings.noKernelTun)}
|
||||
</Tag>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{Array.isArray(inbound.settings.peers) && (inbound.settings.peers as { privateKey: string; publicKey: string; psk: string; allowedIPs?: string[]; keepAlive?: number }[]).map((peer, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<Divider>Peer {idx + 1}</Divider>
|
||||
<dl className="info-list info-list-block">
|
||||
<div className="info-row">
|
||||
<dt>Secret key</dt>
|
||||
<dd><Tag className="value-tag">{peer.privateKey}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>Public key</dt>
|
||||
<dd><Tag className="value-tag">{peer.publicKey}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>PSK</dt>
|
||||
<dd><Tag className="value-tag">{peer.psk}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>Allowed IPs</dt>
|
||||
<dd>
|
||||
{(peer.allowedIPs || []).map((ip, j) => (
|
||||
<Tag key={`wg-ip-${idx}-${j}`} className="value-tag">{ip}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>Keep alive</dt>
|
||||
<dd><Tag>{peer.keepAlive}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
{wireguardConfigs[idx] && (
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">Peer {idx + 1} config</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardConfigs[idx], t)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{wireguardConfigs[idx]}</code>
|
||||
</div>
|
||||
)}
|
||||
{wireguardLinks[idx] && (
|
||||
<div className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">Peer {idx + 1} link</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardLinks[idx], t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{wireguardLinks[idx]}</code>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => (
|
||||
<div key={idx} className="link-panel">
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{link.link}</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const tabItems = [];
|
||||
if (showClientTab) {
|
||||
tabItems.push({ key: 'client', label: t('pages.inbounds.client'), children: clientTab });
|
||||
}
|
||||
tabItems.push({ key: 'inbound', label: t('pages.xray.rules.inbound'), children: inboundTab });
|
||||
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} destroyOnHidden>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-count-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-email-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr:first-child > *:first-child {
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr:first-child > *:last-child {
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > *:first-child {
|
||||
border-end-start-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > *:last-child {
|
||||
border-end-end-radius: 8px;
|
||||
}
|
||||
|
||||
.inbound-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
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;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ant-card-head {
|
||||
padding: 0 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.ant-card-head-title,
|
||||
.ant-card-extra {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Popover,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
type TableColumnType,
|
||||
type MenuProps,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MenuOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
QrcodeOutlined,
|
||||
CopyOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
ReloadOutlined,
|
||||
RetweetOutlined,
|
||||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import InfinityIcon from '@/components/InfinityIcon';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { NodeRecord } from '@/hooks/useNodes';
|
||||
import './InboundList.css';
|
||||
|
||||
type ProtocolFlags = {
|
||||
isVMess?: boolean;
|
||||
isVLess?: boolean;
|
||||
isTrojan?: boolean;
|
||||
isSS?: boolean;
|
||||
isHysteria?: boolean;
|
||||
isMixed?: boolean;
|
||||
isHTTP?: boolean;
|
||||
isWireguard?: boolean;
|
||||
};
|
||||
|
||||
interface DBInboundRecord extends ProtocolFlags {
|
||||
id: number;
|
||||
enable: boolean;
|
||||
remark: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
_expiryTime: unknown;
|
||||
nodeId?: number | null;
|
||||
toInbound: () => {
|
||||
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
|
||||
isSSMultiUser?: boolean;
|
||||
};
|
||||
isMultiUser: () => boolean;
|
||||
}
|
||||
|
||||
export interface ClientCountEntry {
|
||||
clients: number;
|
||||
active: string[];
|
||||
deactive: string[];
|
||||
depleted: string[];
|
||||
expiring: string[];
|
||||
online: string[];
|
||||
}
|
||||
|
||||
export type RowAction =
|
||||
| 'edit'
|
||||
| 'showInfo'
|
||||
| 'qrcode'
|
||||
| 'export'
|
||||
| 'subs'
|
||||
| 'clipboard'
|
||||
| 'delete'
|
||||
| 'resetTraffic'
|
||||
| 'clone';
|
||||
|
||||
export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||
|
||||
interface InboundListProps {
|
||||
dbInbounds: DBInboundRecord[];
|
||||
clientCount: Record<number, ClientCountEntry>;
|
||||
onlineClients: string[];
|
||||
lastOnlineMap: Record<string, number>;
|
||||
expireDiff: number;
|
||||
trafficDiff: number;
|
||||
pageSize: number;
|
||||
isMobile: boolean;
|
||||
subEnable: boolean;
|
||||
nodesById: Map<number, NodeRecord>;
|
||||
hasActiveNode: boolean;
|
||||
onAddInbound: () => void;
|
||||
onGeneralAction: (key: GeneralAction) => void;
|
||||
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
|
||||
}
|
||||
|
||||
type SortKey =
|
||||
| 'id'
|
||||
| 'enable'
|
||||
| 'remark'
|
||||
| 'port'
|
||||
| 'protocol'
|
||||
| 'traffic'
|
||||
| 'expiryTime'
|
||||
| 'node'
|
||||
| 'clients';
|
||||
|
||||
type SortOrder = 'ascend' | 'descend' | null;
|
||||
|
||||
const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
|
||||
id: (a, b) => a.id - b.id,
|
||||
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
||||
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
|
||||
port: (a, b) => a.port - b.port,
|
||||
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
||||
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
||||
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
||||
node: (a, b, ctx) => {
|
||||
const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '' : `node #${a.nodeId}`);
|
||||
const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '' : `node #${b.nodeId}`);
|
||||
return nameA.localeCompare(nameB);
|
||||
},
|
||||
clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
|
||||
};
|
||||
|
||||
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||
if (dbInbound.isWireguard) return true;
|
||||
if (dbInbound.isSS) {
|
||||
try {
|
||||
return !dbInbound.toInbound().isSSMultiUser;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface RowActionsMenuProps {
|
||||
record: DBInboundRecord;
|
||||
subEnable: boolean;
|
||||
onClick: (key: RowAction) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean }): MenuProps['items'] {
|
||||
const items: MenuProps['items'] = [];
|
||||
if (isMobile) {
|
||||
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
|
||||
}
|
||||
if (showQrCodeMenu(record)) {
|
||||
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
|
||||
}
|
||||
if (record.isMultiUser()) {
|
||||
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
|
||||
if (subEnable) {
|
||||
items.push({
|
||||
key: 'subs',
|
||||
icon: <ExportOutlined />,
|
||||
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('info') });
|
||||
}
|
||||
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
||||
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
|
||||
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
|
||||
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
|
||||
return items;
|
||||
}
|
||||
|
||||
function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="action-buttons">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: buildRowActionsMenu({ record, subEnable, t }),
|
||||
onClick: ({ key }) => onClick(key as RowAction),
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InboundList({
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
lastOnlineMap: _lastOnlineMap,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
isMobile,
|
||||
subEnable,
|
||||
nodesById,
|
||||
hasActiveNode,
|
||||
onAddInbound,
|
||||
onGeneralAction,
|
||||
onRowAction,
|
||||
}: InboundListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
|
||||
|
||||
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
|
||||
const previous = dbInbound.enable;
|
||||
dbInbound.enable = next;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enable', String(next));
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
|
||||
if (!msg?.success) dbInbound.enable = previous;
|
||||
} catch {
|
||||
dbInbound.enable = previous;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sortedInbounds = useMemo(() => {
|
||||
if (!sortKey || !sortOrder) return dbInbounds;
|
||||
const fn = SORT_FNS[sortKey];
|
||||
if (!fn) return dbInbounds;
|
||||
const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
|
||||
return sortOrder === 'descend' ? sorted.reverse() : sorted;
|
||||
}, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
|
||||
|
||||
const hasAnyRemark = useMemo(
|
||||
() => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
|
||||
[dbInbounds],
|
||||
);
|
||||
|
||||
const sorterFor = useCallback((key: SortKey) => ({
|
||||
sorter: true as const,
|
||||
showSorterTooltip: false,
|
||||
sortOrder: sortKey === key ? sortOrder : null,
|
||||
sortDirections: ['ascend' as const, 'descend' as const],
|
||||
}), [sortKey, sortOrder]);
|
||||
|
||||
const columns: TableColumnType<DBInboundRecord>[] = useMemo(() => {
|
||||
const cols: TableColumnType<DBInboundRecord>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
align: 'right',
|
||||
width: 30,
|
||||
...sorterFor('id'),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.operate'),
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
width: 60,
|
||||
render: (_, record) => (
|
||||
<RowActionsCell
|
||||
record={record}
|
||||
subEnable={subEnable}
|
||||
onClick={(key) => onRowAction({ key, dbInbound: record })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.enable'),
|
||||
key: 'enable',
|
||||
align: 'center',
|
||||
width: 35,
|
||||
...sorterFor('enable'),
|
||||
render: (_, record) => (
|
||||
<Switch
|
||||
checked={record.enable}
|
||||
onChange={(next) => onSwitchEnable(record, next)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (hasAnyRemark) {
|
||||
cols.push({
|
||||
title: t('pages.inbounds.remark'),
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
align: 'center',
|
||||
width: 60,
|
||||
...sorterFor('remark'),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasActiveNode) {
|
||||
cols.push({
|
||||
title: t('pages.inbounds.node'),
|
||||
key: 'node',
|
||||
align: 'center',
|
||||
width: 60,
|
||||
...sorterFor('node'),
|
||||
render: (_, record) => {
|
||||
if (record.nodeId == null) {
|
||||
return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
|
||||
}
|
||||
const node = nodesById.get(record.nodeId);
|
||||
if (!node) {
|
||||
return <Tag color="orange">node #{record.nodeId}</Tag>;
|
||||
}
|
||||
return (
|
||||
<Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
title: t('pages.inbounds.port'),
|
||||
dataIndex: 'port',
|
||||
key: 'port',
|
||||
align: 'center',
|
||||
width: 40,
|
||||
...sorterFor('port'),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.protocol'),
|
||||
key: 'protocol',
|
||||
align: 'left',
|
||||
width: 130,
|
||||
...sorterFor('protocol'),
|
||||
render: (_, record) => {
|
||||
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
|
||||
if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
|
||||
const stream = record.toInbound().stream;
|
||||
tags.push(
|
||||
<Tag key="n" color="green">
|
||||
{record.isHysteria ? 'UDP' : stream?.network}
|
||||
</Tag>,
|
||||
);
|
||||
if (stream?.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
|
||||
if (stream?.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
|
||||
}
|
||||
return <div className="protocol-tags">{tags}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('clients'),
|
||||
key: 'clients',
|
||||
align: 'left',
|
||||
width: 50,
|
||||
...sorterFor('clients'),
|
||||
render: (_, record) => {
|
||||
const cc = clientCount[record.id];
|
||||
if (!cc) return null;
|
||||
return (
|
||||
<>
|
||||
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{cc.clients}
|
||||
</Tag>
|
||||
{cc.deactive.length > 0 && (
|
||||
<Popover
|
||||
title={t('disabled')}
|
||||
content={(
|
||||
<div className="client-email-list">
|
||||
{cc.deactive.map((e) => <div key={e}>{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
{cc.depleted.length > 0 && (
|
||||
<Popover
|
||||
title={t('depleted')}
|
||||
content={(
|
||||
<div className="client-email-list">
|
||||
{cc.depleted.map((e) => <div key={e}>{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
{cc.expiring.length > 0 && (
|
||||
<Popover
|
||||
title={t('depletingSoon')}
|
||||
content={(
|
||||
<div className="client-email-list">
|
||||
{cc.expiring.map((e) => <div key={e}>{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
{cc.online.length > 0 && (
|
||||
<Popover
|
||||
title={t('online')}
|
||||
content={(
|
||||
<div className="client-email-list">
|
||||
{cc.online.map((e) => <div key={e}>{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.traffic'),
|
||||
key: 'traffic',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
...sorterFor('traffic'),
|
||||
render: (_, record) => (
|
||||
<Popover
|
||||
content={(
|
||||
<table cellPadding={2}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {SizeFormatter.sizeFormat(record.up)}</td>
|
||||
<td>↓ {SizeFormatter.sizeFormat(record.down)}</td>
|
||||
</tr>
|
||||
{record.total > 0 && record.up + record.down < record.total && (
|
||||
<tr>
|
||||
<td>{t('remained')}</td>
|
||||
<td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
>
|
||||
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
|
||||
{SizeFormatter.sizeFormat(record.up + record.down)} /
|
||||
{' '}
|
||||
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
|
||||
</Tag>
|
||||
</Popover>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.expireDate'),
|
||||
key: 'expiryTime',
|
||||
align: 'center',
|
||||
width: 40,
|
||||
...sorterFor('expiryTime'),
|
||||
render: (_, record) => {
|
||||
if (record.expiryTime > 0) {
|
||||
return (
|
||||
<Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
|
||||
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
|
||||
{IntlUtil.formatRelativeTime(record.expiryTime)}
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
return <Tag color="purple"><InfinityIcon /></Tag>;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return cols;
|
||||
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
|
||||
|
||||
const paginationFor = (rows: DBInboundRecord[]) => {
|
||||
const size = pageSize > 0 ? pageSize : rows.length || 1;
|
||||
return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
|
||||
};
|
||||
|
||||
const generalActionsMenu: MenuProps = {
|
||||
items: [
|
||||
{ key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
|
||||
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
|
||||
...(subEnable
|
||||
? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
|
||||
: []),
|
||||
{ key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
|
||||
],
|
||||
onClick: ({ key }) => onGeneralAction(key as GeneralAction),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
title={(
|
||||
<Space>
|
||||
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
|
||||
{!isMobile && t('pages.inbounds.addInbound')}
|
||||
</Button>
|
||||
<Dropdown trigger={['click']} menu={generalActionsMenu}>
|
||||
<Button type="primary" icon={<MenuOutlined />}>
|
||||
{!isMobile && t('pages.inbounds.generalActions')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
{isMobile ? (
|
||||
<div className="inbound-cards">
|
||||
{sortedInbounds.length === 0 ? (
|
||||
<div className="card-empty">—</div>
|
||||
) : (
|
||||
sortedInbounds.map((record) => (
|
||||
<div key={record.id} className="inbound-card">
|
||||
<div className="card-head">
|
||||
<span className="card-id">#{record.id}</span>
|
||||
<span className="tag-name">{record.remark}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={record.enable}
|
||||
size="small"
|
||||
onChange={(next) => onSwitchEnable(record, next)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: buildRowActionsMenu({ record, subEnable, t, isMobile: true }),
|
||||
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={sortedInbounds}
|
||||
rowKey={(r) => r.id}
|
||||
pagination={paginationFor(sortedInbounds)}
|
||||
scroll={{ x: 1000 }}
|
||||
style={{ marginTop: 10 }}
|
||||
size="small"
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const single = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
|
||||
setSortKey(colKey || null);
|
||||
setSortOrder((single?.order as SortOrder) || null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
open={isMobile && !!statsRecord}
|
||||
footer={null}
|
||||
width={360}
|
||||
centered
|
||||
title={statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''}
|
||||
onCancel={() => setStatsRecord(null)}
|
||||
destroyOnHidden
|
||||
>
|
||||
{statsRecord && (
|
||||
<div className="card-stats">
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
|
||||
<Tag color="purple">{statsRecord.protocol}</Tag>
|
||||
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (
|
||||
<>
|
||||
<Tag color="green">
|
||||
{statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network}
|
||||
</Tag>
|
||||
{statsRecord.toInbound().stream?.isTls && <Tag color="blue">TLS</Tag>}
|
||||
{statsRecord.toInbound().stream?.isReality && <Tag color="blue">Reality</Tag>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.inbounds.port')}</span>
|
||||
<Tag>{statsRecord.port}</Tag>
|
||||
</div>
|
||||
{hasActiveNode && (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.inbounds.node')}</span>
|
||||
{statsRecord.nodeId == null ? (
|
||||
<Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
|
||||
) : nodesById.get(statsRecord.nodeId) ? (
|
||||
<Tag color={nodesById.get(statsRecord.nodeId)!.status === 'online' ? 'blue' : 'red'}>
|
||||
{nodesById.get(statsRecord.nodeId)!.name}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="orange">#{statsRecord.nodeId}</Tag>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.inbounds.traffic')}</span>
|
||||
<Tag color={ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)}>
|
||||
{SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} /
|
||||
{' '}
|
||||
{statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : <InfinityIcon />}
|
||||
</Tag>
|
||||
</div>
|
||||
{clientCount[statsRecord.id] && (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('clients')}</span>
|
||||
<Tag color="green" className="client-count-tag">{clientCount[statsRecord.id].clients}</Tag>
|
||||
{clientCount[statsRecord.id].online.length > 0 && (
|
||||
<Tag color="blue">{clientCount[statsRecord.id].online.length} {t('online')}</Tag>
|
||||
)}
|
||||
{clientCount[statsRecord.id].depleted.length > 0 && (
|
||||
<Tag color="red">{clientCount[statsRecord.id].depleted.length} {t('depleted')}</Tag>
|
||||
)}
|
||||
{clientCount[statsRecord.id].expiring.length > 0 && (
|
||||
<Tag color="orange">{clientCount[statsRecord.id].expiring.length} {t('depletingSoon')}</Tag>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.inbounds.expireDate')}</span>
|
||||
{statsRecord.expiryTime > 0 ? (
|
||||
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)}>
|
||||
{IntlUtil.formatRelativeTime(statsRecord.expiryTime)}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="purple"><InfinityIcon /></Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,680 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MenuOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
QrcodeOutlined,
|
||||
CopyOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
ReloadOutlined,
|
||||
RetweetOutlined,
|
||||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
dbInbounds: { type: Array, required: true },
|
||||
clientCount: { type: Object, required: true },
|
||||
onlineClients: { type: Array, required: true },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
// Map node id -> node row, supplied by the parent page so each
|
||||
// inbound row can render its node name without an extra fetch.
|
||||
nodesById: { type: Map, default: () => new Map() },
|
||||
hasActiveNode: { type: Boolean, default: false },
|
||||
statsVersion: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'refresh',
|
||||
'add-inbound',
|
||||
'general-action',
|
||||
'row-action',
|
||||
]);
|
||||
|
||||
// ============ Sorting =================================================
|
||||
const sortState = ref({ column: null, order: null });
|
||||
|
||||
function sortableCol(col, key) {
|
||||
return {
|
||||
...col,
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
sortOrder: sortState.value.column === key ? sortState.value.order : null,
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
};
|
||||
}
|
||||
|
||||
const sortFns = {
|
||||
id: (a, b) => a.id - b.id,
|
||||
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
||||
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
|
||||
port: (a, b) => a.port - b.port,
|
||||
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
||||
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
||||
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
||||
node: (a, b) => {
|
||||
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
|
||||
const nameB = props.nodesById.get(b.nodeId)?.name ?? (b.nodeId == null ? '\uffff' : `node #${b.nodeId}`);
|
||||
return nameA.localeCompare(nameB);
|
||||
},
|
||||
clients: (a, b) => (props.clientCount[a.id]?.clients || 0) - (props.clientCount[b.id]?.clients || 0),
|
||||
};
|
||||
|
||||
const sortedInbounds = computed(() => {
|
||||
const { column, order } = sortState.value;
|
||||
if (!column || !order) return props.dbInbounds;
|
||||
const fn = sortFns[column];
|
||||
if (!fn) return props.dbInbounds;
|
||||
const sorted = [...props.dbInbounds].sort(fn);
|
||||
return order === 'descend' ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
function onTableChange(_pag, _filters, sorter) {
|
||||
sortState.value = {
|
||||
column: sorter?.columnKey || sorter?.field || null,
|
||||
order: sorter?.order || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Columns =================================================
|
||||
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
||||
// `responsive` array still works on column defs. Computed so column
|
||||
// labels react to live locale switches.
|
||||
const hasAnyRemark = computed(() =>
|
||||
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
|
||||
);
|
||||
|
||||
const desktopColumns = computed(() => {
|
||||
const cols = [
|
||||
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
|
||||
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
|
||||
];
|
||||
if (hasAnyRemark.value) {
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
|
||||
}
|
||||
if (props.hasActiveNode) {
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
|
||||
}
|
||||
cols.push(
|
||||
sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
|
||||
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
||||
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
||||
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
||||
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
const columns = computed(() => desktopColumns.value);
|
||||
|
||||
const statsRecord = ref(null);
|
||||
function openStats(record) {
|
||||
statsRecord.value = record;
|
||||
}
|
||||
function closeStats() {
|
||||
statsRecord.value = null;
|
||||
}
|
||||
|
||||
// ============ Pagination ============================================
|
||||
function paginationFor(rows) {
|
||||
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
||||
return {
|
||||
pageSize: size,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Per-row enable switch =================================
|
||||
async function onSwitchEnable(dbInbound, next) {
|
||||
const previous = dbInbound.enable;
|
||||
dbInbound.enable = next; // optimistic
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enable', String(next));
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
|
||||
if (!msg?.success) dbInbound.enable = previous;
|
||||
} catch (_e) {
|
||||
dbInbound.enable = previous;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Helpers shared with the templates =====================
|
||||
// Whether to show the "Switch xray" / qrcode menu entry — same predicate
|
||||
// as legacy: SS single-user inbounds and WireGuard inbounds expose
|
||||
// inbound-wide QR codes.
|
||||
function showQrCodeMenu(dbInbound) {
|
||||
if (dbInbound.isWireguard) return true;
|
||||
if (dbInbound.isSS) {
|
||||
try {
|
||||
return !dbInbound.toInbound().isSSMultiUser;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-space direction="horizontal">
|
||||
<a-button type="primary" @click="emit('add-inbound')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
|
||||
</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="primary">
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('general-action', a.key)">
|
||||
<a-menu-item key="import">
|
||||
<ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetInbounds">
|
||||
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<div v-if="isMobile" class="inbound-cards">
|
||||
<div v-if="sortedInbounds.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: id + remark + info + enable + actions -->
|
||||
<div class="card-head">
|
||||
<span class="card-id">#{{ record.id }}</span>
|
||||
<span class="tag-name">{{ record.remark }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item key="edit">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Mobile: info modal ====================== -->
|
||||
<a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
|
||||
:title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
|
||||
<div v-if="statsRecord" class="card-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||
<a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
|
||||
<template
|
||||
v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
|
||||
<a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="statsRecord.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||
<a-tag>{{ statsRecord.port }}</a-tag>
|
||||
</div>
|
||||
<div v-if="hasActiveNode" class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||
<a-tag v-if="statsRecord.nodeId == null" color="default">
|
||||
{{ t('pages.inbounds.localPanel') }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
|
||||
:color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(statsRecord.nodeId).name }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
|
||||
{{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
|
||||
<template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="clientCount[statsRecord.id]" class="stat-row">
|
||||
<span class="stat-label">{{ t('clients') }}</span>
|
||||
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
|
||||
<a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
|
||||
{{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
|
||||
{{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
|
||||
{{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="statsRecord.expiryTime > 0"
|
||||
:color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
|
||||
{{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- ============== Action dropdown ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<div class="action-buttons">
|
||||
<a-button type="text" size="small" @click.prevent="emit('row-action', { key: 'edit', dbInbound: record })">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small" @click.prevent>
|
||||
<template #icon>
|
||||
<MoreOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Enable switch (desktop) ============== -->
|
||||
<template v-else-if="column.key === 'enable'">
|
||||
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
|
||||
</template>
|
||||
|
||||
<!-- ============== Node deployment tag ============== -->
|
||||
<template v-else-if="column.key === 'node'">
|
||||
<template v-if="record.nodeId == null">
|
||||
<a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="nodesById.get(record.nodeId)">
|
||||
<a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(record.nodeId).name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Node row was deleted but inbound still references it. -->
|
||||
<a-tag color="orange">node #{{ record.nodeId }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============== Protocol tags ============== -->
|
||||
<template v-else-if="column.key === 'protocol'">
|
||||
<div class="protocol-tags">
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
|
||||
<a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Clients tag + popovers ============== -->
|
||||
<template v-else-if="column.key === 'clients'">
|
||||
<template v-if="clientCount[record.id]">
|
||||
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||
clientCount[record.id].clients }}</a-tag>
|
||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||
clientCount[record.id].deactive.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||
clientCount[record.id].depleted.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||
clientCount[record.id].expiring.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||
clientCount[record.id].online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============== Traffic ============== -->
|
||||
<template v-else-if="column.key === 'traffic'">
|
||||
<a-popover>
|
||||
<template #content>
|
||||
<table cellpadding="2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
|
||||
<td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
|
||||
</tr>
|
||||
<tr v-if="record.total > 0 && record.up + record.down < record.total">
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<!-- ============== Expiry ============== -->
|
||||
<template v-else-if="column.key === 'expiryTime'">
|
||||
<a-popover v-if="record.expiryTime > 0">
|
||||
<template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
|
||||
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
|
||||
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-count-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
||||
* token, but the inner header strip and footer touch the edges, so clip
|
||||
* them here. */
|
||||
:deep(.ant-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-container) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr:first-child > *:first-child) {
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr:first-child > *:last-child) {
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child > *:first-child) {
|
||||
border-end-start-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child > *:last-child) {
|
||||
border-end-end-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list ===========================================
|
||||
* <768px renders inbounds as a vertical stack of cards via the
|
||||
* v-if="isMobile" branch above; the desktop <a-table> isn't mounted
|
||||
* so the legacy table-cell tightening rules went away. */
|
||||
.inbound-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:global(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;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.ant-card-head) {
|
||||
padding: 0 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title),
|
||||
:deep(.ant-card-extra) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Layout,
|
||||
Modal,
|
||||
Row,
|
||||
Spin,
|
||||
message,
|
||||
} from 'antd';
|
||||
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import {
|
||||
SwapOutlined,
|
||||
PieChartOutlined,
|
||||
BarsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound.js';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useNodes } from '@/hooks/useNodes';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
import TextModal from '@/components/TextModal';
|
||||
import PromptModal from '@/components/PromptModal';
|
||||
|
||||
import { useInbounds } from './useInbounds';
|
||||
import InboundList from './InboundList';
|
||||
import InboundFormModal from './InboundFormModal';
|
||||
import InboundInfoModal from './InboundInfoModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import '@/styles/page-cards.css';
|
||||
import './InboundsPage.css';
|
||||
|
||||
type RowAction =
|
||||
| 'edit'
|
||||
| 'showInfo'
|
||||
| 'qrcode'
|
||||
| 'export'
|
||||
| 'subs'
|
||||
| 'clipboard'
|
||||
| 'delete'
|
||||
| 'resetTraffic'
|
||||
| 'clone';
|
||||
|
||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||
|
||||
export default function InboundsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const {
|
||||
fetched,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
lastOnlineMap,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
subSettings,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
remarkModel,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
} = useInbounds();
|
||||
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const { nodes: nodesList } = useNodes();
|
||||
const nodesById = useMemo(() => {
|
||||
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
|
||||
for (const n of nodesList || []) map.set(n.id, n);
|
||||
return map;
|
||||
}, [nodesList]);
|
||||
|
||||
const hasActiveNode = useMemo(
|
||||
() => (nodesList || []).some((n) => n.enable && n.status === 'online'),
|
||||
[nodesList],
|
||||
);
|
||||
const hasNodeAttachedInbound = useMemo(
|
||||
() => (dbInbounds || []).some((ib: any) => ib?.nodeId != null),
|
||||
[dbInbounds],
|
||||
);
|
||||
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
|
||||
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
inbounds: applyInboundsEvent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefaultSettings().then(() => refresh());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formDbInbound, setFormDbInbound] = useState<any>(null);
|
||||
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoDbInbound, setInfoDbInbound] = useState<any>(null);
|
||||
const [infoClientIndex, setInfoClientIndex] = useState(0);
|
||||
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrDbInbound, setQrDbInbound] = useState<any>(null);
|
||||
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
const [textTitle, setTextTitle] = useState('');
|
||||
const [textContent, setTextContent] = useState('');
|
||||
const [textFileName, setTextFileName] = useState('');
|
||||
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [promptTitle, setPromptTitle] = useState('');
|
||||
const [promptOkText, setPromptOkText] = useState('OK');
|
||||
const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
|
||||
const [promptInitial, setPromptInitial] = useState('');
|
||||
const [promptLoading, setPromptLoading] = useState(false);
|
||||
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
||||
|
||||
const hostOverrideFor = useCallback((dbInbound: any) => {
|
||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||
return nodesById.get(dbInbound.nodeId)?.address || '';
|
||||
}, [nodesById]);
|
||||
|
||||
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
||||
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
|
||||
|
||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
||||
setTextTitle(opts.title);
|
||||
setTextContent(opts.content);
|
||||
setTextFileName(opts.fileName || '');
|
||||
setTextOpen(true);
|
||||
}, []);
|
||||
|
||||
const openPrompt = useCallback((opts: {
|
||||
title: string;
|
||||
okText?: string;
|
||||
type?: 'textarea' | 'input';
|
||||
value?: string;
|
||||
confirm: (value: string) => Promise<boolean | void> | boolean | void;
|
||||
}) => {
|
||||
setPromptTitle(opts.title);
|
||||
setPromptOkText(opts.okText || 'OK');
|
||||
setPromptType(opts.type || 'textarea');
|
||||
setPromptInitial(opts.value || '');
|
||||
setPromptHandler(() => opts.confirm);
|
||||
setPromptOpen(true);
|
||||
}, []);
|
||||
|
||||
const onPromptConfirm = useCallback(async (value: string) => {
|
||||
if (!promptHandler) {
|
||||
setPromptOpen(false);
|
||||
return;
|
||||
}
|
||||
setPromptLoading(true);
|
||||
try {
|
||||
const ok = await promptHandler(value);
|
||||
if (ok !== false) setPromptOpen(false);
|
||||
} finally {
|
||||
setPromptLoading(false);
|
||||
}
|
||||
}, [promptHandler]);
|
||||
|
||||
const projectChildThroughMaster = useCallback((child: any, master: any) => {
|
||||
const projected = JSON.parse(JSON.stringify(child));
|
||||
projected.listen = master.listen;
|
||||
projected.port = master.port;
|
||||
const masterStream = master.toInbound().stream;
|
||||
const childInbound = child.toInbound();
|
||||
childInbound.stream.security = masterStream.security;
|
||||
childInbound.stream.tls = masterStream.tls;
|
||||
childInbound.stream.reality = masterStream.reality;
|
||||
childInbound.stream.externalProxy = masterStream.externalProxy;
|
||||
projected.streamSettings = childInbound.stream.toString();
|
||||
return new child.constructor(projected);
|
||||
}, []);
|
||||
|
||||
const checkFallback = useCallback((dbInbound: any) => {
|
||||
const parent = dbInbound?.fallbackParent;
|
||||
if (parent?.masterId) {
|
||||
const master = (dbInbounds as any[]).find((ib: any) => 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 (candidate.id === dbInbound.id) continue;
|
||||
const parsed = candidate.toInbound();
|
||||
if (!parsed.isTcp) continue;
|
||||
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
||||
const fallbacks = parsed.settings.fallbacks || [];
|
||||
if (!fallbacks.find((f: { dest?: string }) => f.dest === dbInbound.listen)) continue;
|
||||
return projectChildThroughMaster(dbInbound, candidate);
|
||||
}
|
||||
return dbInbound;
|
||||
}, [dbInbounds, projectChildThroughMaster]);
|
||||
|
||||
const findClientIndex = useCallback((dbInbound: any, client: any) => {
|
||||
if (!client) return 0;
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = clients.findIndex((c: any) => {
|
||||
if (!c) return false;
|
||||
switch (dbInbound.protocol) {
|
||||
case 'trojan':
|
||||
case 'shadowsocks':
|
||||
return c.password === client.password && c.email === client.email;
|
||||
default:
|
||||
return c.id === client.id && c.email === client.email;
|
||||
}
|
||||
});
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, []);
|
||||
|
||||
const exportInboundLinks = useCallback((dbInbound: any) => {
|
||||
const projected = checkFallback(dbInbound);
|
||||
openText({
|
||||
title: 'Export inbound links',
|
||||
content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
|
||||
fileName: projected.remark || 'inbound',
|
||||
});
|
||||
}, [checkFallback, remarkModel, hostOverrideFor, openText]);
|
||||
|
||||
const exportInboundClipboard = useCallback((dbInbound: any) => {
|
||||
openText({ title: 'Inbound JSON', content: JSON.stringify(dbInbound, null, 2) });
|
||||
}, [openText]);
|
||||
|
||||
const exportInboundSubs = useCallback((dbInbound: any) => {
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const subLinks: string[] = [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.subURI) {
|
||||
subLinks.push(subSettings.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export subscription links',
|
||||
content: [...new Set(subLinks)].join('\n'),
|
||||
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
|
||||
});
|
||||
}, [subSettings, openText]);
|
||||
|
||||
const exportAllLinks = useCallback(() => {
|
||||
const out: string[] = [];
|
||||
for (const ib of dbInbounds as any[]) {
|
||||
const projected = checkFallback(ib);
|
||||
out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
|
||||
}
|
||||
openText({ title: 'Export all inbound links', content: out.join('\r\n'), fileName: 'All-Inbounds' });
|
||||
}, [dbInbounds, checkFallback, remarkModel, hostOverrideFor, openText]);
|
||||
|
||||
const exportAllSubs = useCallback(() => {
|
||||
const out: string[] = [];
|
||||
for (const ib of dbInbounds as any[]) {
|
||||
const inbound = ib.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.subURI) {
|
||||
out.push(subSettings.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
}
|
||||
openText({ title: 'Export all subscription links', content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
|
||||
}, [dbInbounds, subSettings, openText]);
|
||||
|
||||
const importInbound = useCallback(() => {
|
||||
openPrompt({
|
||||
title: 'Import inbound',
|
||||
okText: 'Import',
|
||||
type: 'textarea',
|
||||
value: '',
|
||||
confirm: async (value) => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
||||
if (msg?.success) {
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}, [openPrompt, refresh]);
|
||||
|
||||
const onAddInbound = useCallback(() => {
|
||||
setFormMode('add');
|
||||
setFormDbInbound(null);
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((dbInbound: any) => {
|
||||
setFormMode('edit');
|
||||
setFormDbInbound(dbInbound);
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback((dbInbound: any) => {
|
||||
modal.confirm({
|
||||
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}, [modal, refresh]);
|
||||
|
||||
const confirmResetTraffic = useCallback((dbInbound: any) => {
|
||||
modal.confirm({
|
||||
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||
content: 'Resets up/down counters to 0 for this inbound.',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}, [modal, refresh]);
|
||||
|
||||
const confirmClone = useCallback((dbInbound: any) => {
|
||||
modal.confirm({
|
||||
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||
content: 'Creates a copy with a new port and an empty client list.',
|
||||
okText: 'Clone',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const baseInbound = dbInbound.toInbound();
|
||||
let clonedSettings: string;
|
||||
try {
|
||||
const raw = coerceInboundJsonField(dbInbound.settings);
|
||||
raw.clients = [];
|
||||
clonedSettings = JSON.stringify(raw);
|
||||
} catch {
|
||||
clonedSettings = (Inbound as any).Settings.getSettings(baseInbound.protocol).toString();
|
||||
}
|
||||
const data = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
remark: `${dbInbound.remark} (clone)`,
|
||||
enable: false,
|
||||
expiryTime: 0,
|
||||
listen: '',
|
||||
port: RandomUtil.randomInteger(10000, 60000),
|
||||
protocol: baseInbound.protocol,
|
||||
settings: clonedSettings,
|
||||
streamSettings: baseInbound.stream.toString(),
|
||||
sniffing: baseInbound.sniffing.toString(),
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}, [modal, refresh]);
|
||||
|
||||
const onGeneralAction = useCallback((key: GeneralAction) => {
|
||||
switch (key) {
|
||||
case 'import': importInbound(); break;
|
||||
case 'export': exportAllLinks(); break;
|
||||
case 'subs': exportAllSubs(); break;
|
||||
case 'resetInbounds':
|
||||
modal.confirm({
|
||||
title: 'Reset all inbound traffic?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
|
||||
|
||||
const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
openEdit(dbInbound);
|
||||
break;
|
||||
case 'showInfo':
|
||||
setInfoDbInbound(checkFallback(dbInbound));
|
||||
setInfoClientIndex(findClientIndex(dbInbound, null));
|
||||
setInfoOpen(true);
|
||||
break;
|
||||
case 'qrcode':
|
||||
setQrDbInbound(checkFallback(dbInbound));
|
||||
setQrOpen(true);
|
||||
break;
|
||||
case 'export':
|
||||
exportInboundLinks(dbInbound);
|
||||
break;
|
||||
case 'subs':
|
||||
exportInboundSubs(dbInbound);
|
||||
break;
|
||||
case 'clipboard':
|
||||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
break;
|
||||
case 'resetTraffic':
|
||||
confirmResetTraffic(dbInbound);
|
||||
break;
|
||||
case 'clone':
|
||||
confirmClone(dbInbound);
|
||||
break;
|
||||
default:
|
||||
messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
|
||||
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description="Loading…" size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
||||
<Col span={24}>
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={12} sm={12} md={8}>
|
||||
<CustomStatistic
|
||||
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
|
||||
title={t('pages.inbounds.totalUsage')}
|
||||
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
|
||||
prefix={<PieChartOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8}>
|
||||
<CustomStatistic
|
||||
title={t('pages.inbounds.inboundCount')}
|
||||
value={String(dbInbounds.length)}
|
||||
prefix={<BarsOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<InboundList
|
||||
dbInbounds={dbInbounds as any}
|
||||
clientCount={clientCount}
|
||||
onlineClients={onlineClients}
|
||||
lastOnlineMap={lastOnlineMap}
|
||||
expireDiff={expireDiff}
|
||||
trafficDiff={trafficDiff}
|
||||
pageSize={pageSize}
|
||||
isMobile={isMobile}
|
||||
subEnable={subSettings.enable}
|
||||
nodesById={nodesById}
|
||||
hasActiveNode={showNodeInfo}
|
||||
onAddInbound={onAddInbound}
|
||||
onGeneralAction={onGeneralAction}
|
||||
onRowAction={onRowAction}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<InboundFormModal
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSaved={refresh}
|
||||
mode={formMode}
|
||||
dbInbound={formDbInbound}
|
||||
dbInbounds={dbInbounds as any[]}
|
||||
/>
|
||||
<InboundInfoModal
|
||||
open={infoOpen}
|
||||
onClose={() => setInfoOpen(false)}
|
||||
dbInbound={infoDbInbound}
|
||||
clientIndex={infoClientIndex}
|
||||
remarkModel={remarkModel}
|
||||
expireDiff={expireDiff}
|
||||
trafficDiff={trafficDiff}
|
||||
ipLimitEnable={ipLimitEnable}
|
||||
tgBotEnable={tgBotEnable}
|
||||
subSettings={subSettings}
|
||||
lastOnlineMap={lastOnlineMap}
|
||||
nodeAddress={infoNodeAddress}
|
||||
/>
|
||||
<QrCodeModal
|
||||
open={qrOpen}
|
||||
onClose={() => setQrOpen(false)}
|
||||
dbInbound={qrDbInbound}
|
||||
client={null}
|
||||
remarkModel={remarkModel}
|
||||
nodeAddress={qrNodeAddress}
|
||||
subSettings={subSettings}
|
||||
/>
|
||||
|
||||
<TextModal
|
||||
open={textOpen}
|
||||
onClose={() => setTextOpen(false)}
|
||||
title={textTitle}
|
||||
content={textContent}
|
||||
fileName={textFileName}
|
||||
/>
|
||||
<PromptModal
|
||||
open={promptOpen}
|
||||
onClose={() => setPromptOpen(false)}
|
||||
title={promptTitle}
|
||||
okText={promptOkText}
|
||||
type={promptType}
|
||||
initialValue={promptInitial}
|
||||
loading={promptLoading}
|
||||
onConfirm={onPromptConfirm}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
SwapOutlined,
|
||||
PieChartOutlined,
|
||||
BarsOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound.js';
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import { useNodeList } from '@/composables/useNodeList.js';
|
||||
import InboundList from './InboundList.vue';
|
||||
import InboundFormModal from './InboundFormModal.vue';
|
||||
import InboundInfoModal from './InboundInfoModal.vue';
|
||||
import QrCodeModal from './QrCodeModal.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
import PromptModal from '@/components/PromptModal.vue';
|
||||
import { useInbounds } from './useInbounds.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
fetched,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
subSettings,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
remarkModel,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
} = useInbounds();
|
||||
|
||||
// Live updates over WebSocket — replaces the old 5s polling loop.
|
||||
// The backend pushes traffic + per-client deltas every ~10s; we merge
|
||||
// them into the local refs in-place so counters and online badges
|
||||
// update without re-fetching the whole list.
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
inbounds: applyInboundsEvent,
|
||||
});
|
||||
const { isMobile } = useMediaQuery();
|
||||
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
|
||||
const hasNodeAttachedInbound = computed(() =>
|
||||
(dbInbounds.value || []).some((ib) => ib?.nodeId != null),
|
||||
);
|
||||
const showNodeInfo = computed(() => hasNodeAttachedInbound.value || hasActiveNode.value);
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDefaultSettings();
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// === Add/Edit modal ===================================================
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const formDbInbound = ref(null);
|
||||
|
||||
// === Info / QR-code modals ===========================================
|
||||
const infoOpen = ref(false);
|
||||
const infoDbInbound = ref(null);
|
||||
const infoClientIndex = ref(0);
|
||||
|
||||
const qrOpen = ref(false);
|
||||
const qrDbInbound = ref(null);
|
||||
const qrClient = ref(null);
|
||||
|
||||
// hostOverrideFor returns the node's address for a node-managed inbound,
|
||||
// or '' when the inbound runs locally. Wired into the QR / Info modals
|
||||
// and into export-all-links functions so generated share links point at
|
||||
// the node, not the central panel.
|
||||
function hostOverrideFor(dbInbound) {
|
||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||
return nodesById.value.get(dbInbound.nodeId)?.address || '';
|
||||
}
|
||||
|
||||
const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
|
||||
const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
|
||||
|
||||
// === Shared text + prompt modal state =================================
|
||||
const textOpen = ref(false);
|
||||
const textTitle = ref('');
|
||||
const textContent = ref('');
|
||||
const textFileName = ref('');
|
||||
|
||||
const promptOpen = ref(false);
|
||||
const promptTitle = ref('');
|
||||
const promptOkText = ref('OK');
|
||||
const promptType = ref('textarea');
|
||||
const promptInitial = ref('');
|
||||
const promptLoading = ref(false);
|
||||
let promptHandler = null;
|
||||
|
||||
function openText({ title, content, fileName = '' }) {
|
||||
textTitle.value = title;
|
||||
textContent.value = content;
|
||||
textFileName.value = fileName;
|
||||
textOpen.value = true;
|
||||
}
|
||||
|
||||
function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
|
||||
promptTitle.value = title;
|
||||
promptOkText.value = okText || 'OK';
|
||||
promptType.value = type;
|
||||
promptInitial.value = value;
|
||||
promptHandler = confirm;
|
||||
promptOpen.value = true;
|
||||
}
|
||||
|
||||
async function onPromptConfirm(value) {
|
||||
if (!promptHandler) { promptOpen.value = false; return; }
|
||||
promptLoading.value = true;
|
||||
try {
|
||||
const ok = await promptHandler(value);
|
||||
if (ok !== false) promptOpen.value = false;
|
||||
} finally {
|
||||
promptLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Export helpers — mirror legacy txtModal call sites ==============
|
||||
function exportInboundLinks(dbInbound) {
|
||||
const projected = checkFallback(dbInbound);
|
||||
openText({
|
||||
title: 'Export inbound links',
|
||||
content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
|
||||
fileName: projected.remark || 'inbound',
|
||||
});
|
||||
}
|
||||
|
||||
function exportInboundClipboard(dbInbound) {
|
||||
openText({
|
||||
title: 'Inbound JSON',
|
||||
content: JSON.stringify(dbInbound, null, 2),
|
||||
});
|
||||
}
|
||||
|
||||
function exportInboundSubs(dbInbound) {
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const subLinks = [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.value.subURI) {
|
||||
subLinks.push(subSettings.value.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export subscription links',
|
||||
content: [...new Set(subLinks)].join('\n'),
|
||||
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
|
||||
});
|
||||
}
|
||||
|
||||
function exportAllLinks() {
|
||||
const out = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
const projected = checkFallback(ib);
|
||||
out.push(projected.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
|
||||
}
|
||||
openText({
|
||||
title: 'Export all inbound links',
|
||||
content: out.join('\r\n'),
|
||||
fileName: 'All-Inbounds',
|
||||
});
|
||||
}
|
||||
|
||||
function exportAllSubs() {
|
||||
const out = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
const inbound = ib.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.value.subURI) {
|
||||
out.push(subSettings.value.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export all subscription links',
|
||||
content: [...new Set(out)].join('\r\n'),
|
||||
fileName: 'All-Inbounds-Subs',
|
||||
});
|
||||
}
|
||||
|
||||
function importInbound() {
|
||||
openPrompt({
|
||||
title: 'Import inbound',
|
||||
okText: 'Import',
|
||||
type: 'textarea',
|
||||
value: '',
|
||||
confirm: async (value) => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
||||
if (msg?.success) {
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// `checkFallback` mirrors the legacy helper: when an inbound listens
|
||||
// on a unix-socket fallback (`@<name>`), point the link generator at
|
||||
// the root inbound that owns the listen address so QRs/links carry
|
||||
// the externally-reachable host:port and the right TLS state.
|
||||
function checkFallback(dbInbound) {
|
||||
// Path 1: panel-tracked fallback relationship (inbound_fallbacks row).
|
||||
// The backend annotates each child inbound with fallbackParent so the
|
||||
// child's client-share link advertises the master's reachable endpoint
|
||||
// and inherits its TLS / Reality state.
|
||||
const parent = dbInbound.fallbackParent;
|
||||
if (parent?.masterId) {
|
||||
const master = dbInbounds.value.find((ib) => ib.id === parent.masterId);
|
||||
if (master) return projectChildThroughMaster(dbInbound, master);
|
||||
}
|
||||
// Path 2: legacy unix-socket convention (`@vless-ws` etc.) — walk the
|
||||
// VLESS/Trojan TCP inbounds and look for one whose settings.fallbacks
|
||||
// references this child's listen address.
|
||||
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds.value) {
|
||||
if (candidate.id === dbInbound.id) continue;
|
||||
const parsed = candidate.toInbound();
|
||||
if (!parsed.isTcp) continue;
|
||||
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
||||
const fallbacks = parsed.settings.fallbacks || [];
|
||||
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
||||
return projectChildThroughMaster(dbInbound, candidate);
|
||||
}
|
||||
return dbInbound;
|
||||
}
|
||||
|
||||
// projectChildThroughMaster returns a one-off DBInbound copy whose
|
||||
// listen/port + TLS/Reality state come from the master, while the
|
||||
// protocol/transport/clients stay the child's. This is what makes a
|
||||
// `vless://uuid@server:443?type=ws&path=/vlws&security=tls` link work
|
||||
// for a child VLESS-WS bound to 127.0.0.1.
|
||||
function projectChildThroughMaster(child, master) {
|
||||
const projected = JSON.parse(JSON.stringify(child));
|
||||
projected.listen = master.listen;
|
||||
projected.port = master.port;
|
||||
const masterStream = master.toInbound().stream;
|
||||
const childInbound = child.toInbound();
|
||||
childInbound.stream.security = masterStream.security;
|
||||
childInbound.stream.tls = masterStream.tls;
|
||||
childInbound.stream.reality = masterStream.reality;
|
||||
childInbound.stream.externalProxy = masterStream.externalProxy;
|
||||
projected.streamSettings = childInbound.stream.toString();
|
||||
return new child.constructor(projected);
|
||||
}
|
||||
|
||||
function findClientIndex(dbInbound, client) {
|
||||
if (!client) return 0;
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = clients.findIndex((c) => {
|
||||
if (!c) return false;
|
||||
switch (dbInbound.protocol) {
|
||||
case 'trojan':
|
||||
case 'shadowsocks':
|
||||
return c.password === client.password && c.email === client.email;
|
||||
default:
|
||||
return c.id === client.id && c.email === client.email;
|
||||
}
|
||||
});
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function onAddInbound() {
|
||||
formMode.value = 'add';
|
||||
formDbInbound.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(dbInbound) {
|
||||
formMode.value = 'edit';
|
||||
formDbInbound.value = dbInbound;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||
function confirmDelete(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmResetTraffic(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||
content: 'Resets up/down counters to 0 for this inbound.',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||
// but a fresh remark/port and an empty client list.
|
||||
function confirmClone(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||
content: 'Creates a copy with a new port and an empty client list.',
|
||||
okText: 'Clone',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const baseInbound = dbInbound.toInbound();
|
||||
let clonedSettings;
|
||||
try {
|
||||
const raw = coerceInboundJsonField(dbInbound.settings);
|
||||
raw.clients = [];
|
||||
clonedSettings = JSON.stringify(raw);
|
||||
} catch (_e) {
|
||||
clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
|
||||
}
|
||||
const data = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
remark: `${dbInbound.remark} (clone)`,
|
||||
enable: false,
|
||||
expiryTime: 0,
|
||||
listen: '',
|
||||
port: RandomUtil.randomInteger(10000, 60000),
|
||||
protocol: baseInbound.protocol,
|
||||
settings: clonedSettings,
|
||||
streamSettings: baseInbound.stream.toString(),
|
||||
sniffing: baseInbound.sniffing.toString(),
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onGeneralAction(key) {
|
||||
switch (key) {
|
||||
case 'import':
|
||||
importInbound();
|
||||
break;
|
||||
case 'export':
|
||||
exportAllLinks();
|
||||
break;
|
||||
case 'subs':
|
||||
exportAllSubs();
|
||||
break;
|
||||
case 'resetInbounds':
|
||||
Modal.confirm({
|
||||
title: 'Reset all inbound traffic?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}
|
||||
|
||||
function onRowAction({ key, dbInbound }) {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
openEdit(dbInbound);
|
||||
break;
|
||||
case 'showInfo':
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||
infoOpen.value = true;
|
||||
break;
|
||||
case 'qrcode':
|
||||
qrDbInbound.value = checkFallback(dbInbound);
|
||||
qrClient.value = null;
|
||||
qrOpen.value = true;
|
||||
break;
|
||||
case 'export':
|
||||
exportInboundLinks(dbInbound);
|
||||
break;
|
||||
case 'subs':
|
||||
exportInboundSubs(dbInbound);
|
||||
break;
|
||||
case 'clipboard':
|
||||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
break;
|
||||
case 'resetTraffic':
|
||||
confirmResetTraffic(dbInbound);
|
||||
break;
|
||||
case 'clone':
|
||||
confirmClone(dbInbound);
|
||||
break;
|
||||
default:
|
||||
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="12" :sm="12" :md="8">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="8">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||
<template #prefix>
|
||||
<PieChartOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="8">
|
||||
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||
<template #prefix>
|
||||
<BarsOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Inbound list — toolbar, search/filter, columns, row actions -->
|
||||
<a-col :span="24">
|
||||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="showNodeInfo"
|
||||
:stats-version="statsVersion" @refresh="refresh" @add-inbound="onAddInbound"
|
||||
@general-action="onGeneralAction" @row-action="onRowAction" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" :db-inbounds="dbInbounds"
|
||||
@saved="refresh" />
|
||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
||||
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
||||
:node-address="qrNodeAddress" :sub-settings="subSettings" />
|
||||
|
||||
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
||||
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
||||
:initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbounds-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.inbounds-page :deep(.ant-layout),
|
||||
.inbounds-page :deep(.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Modal } from 'antd';
|
||||
import type { CollapseProps } from 'antd';
|
||||
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import QrPanel from './QrPanel';
|
||||
import type { SubSettings } from './useInbounds';
|
||||
|
||||
interface ClientSetting {
|
||||
email?: string;
|
||||
subId?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface DBInboundLike {
|
||||
remark?: string;
|
||||
toInbound: () => InboundLike;
|
||||
}
|
||||
|
||||
interface InboundLike {
|
||||
protocol: string;
|
||||
genWireguardConfigs: (remark: string, model: string, host: string) => string;
|
||||
genWireguardLinks: (remark: string, model: string, host: string) => string;
|
||||
genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[];
|
||||
}
|
||||
|
||||
interface QrCodeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dbInbound: DBInboundLike | null;
|
||||
client?: ClientSetting | null;
|
||||
remarkModel?: string;
|
||||
nodeAddress?: string;
|
||||
subSettings?: SubSettings;
|
||||
}
|
||||
|
||||
interface QrItem {
|
||||
key: string;
|
||||
header: string;
|
||||
value: string;
|
||||
downloadName?: string;
|
||||
}
|
||||
|
||||
export default function QrCodeModal({
|
||||
open,
|
||||
onClose,
|
||||
dbInbound,
|
||||
client = null,
|
||||
remarkModel = '-ieo',
|
||||
nodeAddress = '',
|
||||
subSettings,
|
||||
}: QrCodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||
const [subLink, setSubLink] = useState('');
|
||||
const [subJsonLink, setSubJsonLink] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !dbInbound) return;
|
||||
const inbound = dbInbound.toInbound();
|
||||
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||
const peerRemark = client?.email
|
||||
? `${dbInbound.remark}-${client.email}`
|
||||
: dbInbound.remark || '';
|
||||
setWireguardConfigs(inbound.genWireguardConfigs(peerRemark, '-ieo', nodeAddress).split('\r\n'));
|
||||
setWireguardLinks(inbound.genWireguardLinks(peerRemark, '-ieo', nodeAddress).split('\r\n'));
|
||||
setLinks([]);
|
||||
} else {
|
||||
setLinks(inbound.genAllLinks(dbInbound.remark || '', remarkModel, client, nodeAddress) as { remark?: string; link: string }[]);
|
||||
setWireguardConfigs([]);
|
||||
setWireguardLinks([]);
|
||||
}
|
||||
|
||||
const subId = client?.subId;
|
||||
let nextSub = '';
|
||||
let nextSubJson = '';
|
||||
if (subSettings?.enable && subId) {
|
||||
nextSub = (subSettings.subURI || '') + subId;
|
||||
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
|
||||
}
|
||||
setSubLink(nextSub);
|
||||
setSubJsonLink(nextSubJson);
|
||||
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
|
||||
|
||||
const qrItems = useMemo<QrItem[]>(() => {
|
||||
const items: QrItem[] = [];
|
||||
if (subLink) {
|
||||
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
|
||||
}
|
||||
if (subJsonLink) {
|
||||
items.push({ key: 'sub-json', header: `${t('subscription.title')} (JSON)`, value: subJsonLink });
|
||||
}
|
||||
links.forEach((link, idx) => {
|
||||
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
|
||||
});
|
||||
wireguardConfigs.forEach((cfg, idx) => {
|
||||
items.push({
|
||||
key: `wc${idx}`,
|
||||
header: `Peer ${idx + 1} config`,
|
||||
value: cfg,
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks[idx]) {
|
||||
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx] });
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
|
||||
|
||||
const collapseItems: CollapseProps['items'] = useMemo(
|
||||
() => qrItems.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.header,
|
||||
children: (
|
||||
<QrPanel
|
||||
value={item.value}
|
||||
remark={item.header}
|
||||
downloadName={item.downloadName || ''}
|
||||
showQr={!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[qrItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveKey([]);
|
||||
return;
|
||||
}
|
||||
setActiveKey(qrItems.length > 0 ? [qrItems[0].key] : []);
|
||||
}, [open, qrItems]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} title={t('qrCode')} footer={null} width={420} destroyOnHidden>
|
||||
{dbInbound && collapseItems && collapseItems.length > 0 && (
|
||||
<Collapse
|
||||
ghost
|
||||
activeKey={activeKey}
|
||||
onChange={(keys) => setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))}
|
||||
items={collapseItems}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import QrPanel from './QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
client: { type: Object, default: null },
|
||||
remarkModel: { type: String, default: '-ieo' },
|
||||
nodeAddress: { type: String, default: '' },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const wireguardConfigs = ref([]);
|
||||
const wireguardLinks = ref([]);
|
||||
const subLink = ref('');
|
||||
const subJsonLink = ref('');
|
||||
const activeKeys = ref([]);
|
||||
|
||||
const qrItems = computed(() => {
|
||||
const items = [];
|
||||
if (subLink.value) {
|
||||
items.push({
|
||||
key: 'sub',
|
||||
header: t('subscription.title'),
|
||||
value: subLink.value,
|
||||
});
|
||||
}
|
||||
if (subJsonLink.value) {
|
||||
items.push({
|
||||
key: 'sub-json',
|
||||
header: `${t('subscription.title')} (JSON)`,
|
||||
value: subJsonLink.value,
|
||||
});
|
||||
}
|
||||
links.value.forEach((link, idx) => {
|
||||
items.push({
|
||||
key: `l${idx}`,
|
||||
header: link.remark || `Link ${idx + 1}`,
|
||||
value: link.link,
|
||||
});
|
||||
});
|
||||
wireguardConfigs.value.forEach((cfg, idx) => {
|
||||
items.push({
|
||||
key: `wc${idx}`,
|
||||
header: `Peer ${idx + 1} config`,
|
||||
value: cfg,
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks.value[idx]) {
|
||||
items.push({
|
||||
key: `wl${idx}`,
|
||||
header: `Peer ${idx + 1} link`,
|
||||
value: wireguardLinks.value[idx],
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next || !props.dbInbound) return;
|
||||
const inbound = props.dbInbound.toInbound();
|
||||
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||
const peerRemark = props.client?.email
|
||||
? `${props.dbInbound.remark}-${props.client.email}`
|
||||
: props.dbInbound.remark;
|
||||
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||
wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||
links.value = [];
|
||||
} else {
|
||||
// When a client is provided we generate per-client share links;
|
||||
// otherwise (single-user SS) fall back to the inbound's settings.
|
||||
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
|
||||
wireguardConfigs.value = [];
|
||||
wireguardLinks.value = [];
|
||||
}
|
||||
|
||||
const subId = props.client?.subId;
|
||||
if (props.subSettings?.enable && subId) {
|
||||
subLink.value = (props.subSettings.subURI || '') + subId;
|
||||
subJsonLink.value = props.subSettings.subJsonEnable
|
||||
? (props.subSettings.subJsonURI || '') + subId
|
||||
: '';
|
||||
} else {
|
||||
subLink.value = '';
|
||||
subJsonLink.value = '';
|
||||
}
|
||||
const open = [];
|
||||
if (subLink.value) open.push('sub');
|
||||
activeKeys.value = open;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
||||
<template v-if="dbInbound">
|
||||
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
|
||||
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
|
||||
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''"
|
||||
:show-qr="!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-collapse :deep(.ant-collapse-content-box) {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
.qr-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qr-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qr-remark {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, QRCode, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
import './QrPanel.css';
|
||||
|
||||
interface QrPanelProps {
|
||||
value: string;
|
||||
remark?: string;
|
||||
downloadName?: string;
|
||||
size?: number;
|
||||
showQr?: boolean;
|
||||
}
|
||||
|
||||
async function svgToPngBlob(svgEl: SVGSVGElement | null, size: number): Promise<Blob | null> {
|
||||
if (!svgEl) return null;
|
||||
const svgData = new XMLSerializer().serializeToString(svgEl);
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png');
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadImageBlob(blob: Blob, remark: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${remark || 'qrcode'}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function QrPanel({
|
||||
value,
|
||||
remark = '',
|
||||
downloadName = '',
|
||||
size = 360,
|
||||
showQr = true,
|
||||
}: QrPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const qrRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(value);
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!downloadName) return;
|
||||
FileManager.downloadTextFile(value, downloadName);
|
||||
}
|
||||
|
||||
async function copyImage() {
|
||||
const svgEl = qrRef.current?.querySelector('svg') as SVGSVGElement | null;
|
||||
const blob = await svgToPngBlob(svgEl, size);
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
messageApi.success(t('copied'));
|
||||
} catch {
|
||||
downloadImageBlob(blob, remark);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImage() {
|
||||
const svgEl = qrRef.current?.querySelector('svg') as SVGSVGElement | null;
|
||||
const blob = await svgToPngBlob(svgEl, size);
|
||||
if (blob) downloadImageBlob(blob, remark);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="qr-panel">
|
||||
{messageContextHolder}
|
||||
<div className="qr-panel-header">
|
||||
<Tag color="green" className="qr-remark">{remark}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
|
||||
</Tooltip>
|
||||
{showQr && (
|
||||
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}>
|
||||
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadName && (
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={download} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{showQr && (
|
||||
<div ref={qrRef} className="qr-panel-canvas">
|
||||
<Tooltip title={t('copy')}>
|
||||
<QRCode
|
||||
className="qr-code"
|
||||
value={value}
|
||||
size={size}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
onClick={copyImage}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, required: true },
|
||||
remark: { type: String, default: '' },
|
||||
downloadName: { type: String, default: '' },
|
||||
size: { type: Number, default: 360 },
|
||||
showQr: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const qrRef = ref(null);
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(props.value);
|
||||
if (ok) message.success(t('copied'));
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!props.downloadName) return;
|
||||
FileManager.downloadTextFile(props.value, props.downloadName);
|
||||
}
|
||||
|
||||
function svgToPngBlob(size = 360) {
|
||||
const svgEl = qrRef.value?.querySelector('svg');
|
||||
if (!svgEl) return Promise.resolve(null);
|
||||
const svgData = new XMLSerializer().serializeToString(svgEl);
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(resolve, 'image/png');
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function copyImage() {
|
||||
const blob = await svgToPngBlob(props.size);
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
message.success(t('copied'));
|
||||
} catch {
|
||||
downloadImageBlob(blob);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImageBlob(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${props.remark || 'qrcode'}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadImage() {
|
||||
const blob = await svgToPngBlob(props.size);
|
||||
if (blob) downloadImageBlob(blob);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qr-panel">
|
||||
<div class="qr-panel-header">
|
||||
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-button size="small" @click="copy">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="showQr" :title="t('downloadImage', 'Download Image')">
|
||||
<a-button size="small" @click="downloadImage">
|
||||
<template #icon>
|
||||
<PictureOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="downloadName" :title="t('download')">
|
||||
<a-button size="small" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="showQr" ref="qrRef" class="qr-panel-canvas">
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" color="#000000"
|
||||
bg-color="#ffffff" @click="copyImage" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qr-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qr-remark {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code :deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,329 +0,0 @@
|
||||
// Loads the inbound list + sidecar data the page needs (online users,
|
||||
// last-online-map, default settings) and computes the per-inbound client
|
||||
// roll-ups the legacy panel surfaces in the popovers.
|
||||
//
|
||||
// Live-update model: initial GET on mount, then the WebSocket delta path
|
||||
// keeps the table fresh — the page subscribes to the server's `traffic`,
|
||||
// `client_stats`, and `invalidate` events and merges them into local
|
||||
// refs in-place. The manual refresh button is kept as a fallback.
|
||||
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil, ObjectUtil } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { setDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
export function useInbounds() {
|
||||
const fetched = ref(false);
|
||||
const refreshing = ref(false);
|
||||
|
||||
// shallowRef because each refresh swaps the array; per-row reactivity is
|
||||
// unnecessary at the page level (modals work on copies).
|
||||
const dbInbounds = shallowRef([]);
|
||||
const clientCount = ref({});
|
||||
const onlineClients = ref([]);
|
||||
const lastOnlineMap = ref({});
|
||||
// Bumps on every client_stats merge so the per-inbound ClientRowTable
|
||||
// child can re-render. DBInbound is a plain class instance, not reactive,
|
||||
// so the in-place mutations on its clientStats array are invisible to
|
||||
// Vue's tracking unless something else (this tick) signals the change.
|
||||
const statsVersion = ref(0);
|
||||
|
||||
// Default-settings sidecar fields the table needs for color/expiry math.
|
||||
const expireDiff = ref(0);
|
||||
const trafficDiff = ref(0);
|
||||
const subSettings = ref({
|
||||
enable: false,
|
||||
subTitle: '',
|
||||
subURI: '',
|
||||
subJsonURI: '',
|
||||
subJsonEnable: false,
|
||||
});
|
||||
const remarkModel = ref('-ieo');
|
||||
const datepicker = ref('gregorian');
|
||||
const tgBotEnable = ref(false);
|
||||
const ipLimitEnable = ref(false);
|
||||
const pageSize = ref(0);
|
||||
|
||||
function isClientOnline(email) {
|
||||
return onlineClients.value.includes(email);
|
||||
}
|
||||
|
||||
// Roll-up of {clients, active, deactive, depleted, expiring, online,
|
||||
// comments} for a single inbound. Mirrors getClientCounts in the legacy
|
||||
// template. Skipped for protocols that don't have multi-user clients
|
||||
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||
function rollupClients(dbInbound, inbound) {
|
||||
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
||||
const allClients = inbound?.clients || [];
|
||||
const statsEmails = new Set();
|
||||
for (const s of clientStats) {
|
||||
if (s && s.email) statsEmails.add(s.email);
|
||||
}
|
||||
const clients = clientStats.length > 0
|
||||
? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
|
||||
: allClients;
|
||||
const active = [];
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
const comments = new Map();
|
||||
const now = Date.now();
|
||||
|
||||
if (dbInbound.enable) {
|
||||
for (const client of clients) {
|
||||
if (client.comment) comments.set(client.email, client.comment);
|
||||
if (client.enable) {
|
||||
active.push(client.email);
|
||||
if (isClientOnline(client.email)) online.push(client.email);
|
||||
} else {
|
||||
deactive.push(client.email);
|
||||
}
|
||||
}
|
||||
for (const stats of clientStats) {
|
||||
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
|
||||
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
|
||||
if (expired || exhausted) {
|
||||
depleted.push(stats.email);
|
||||
} else {
|
||||
const expiringSoon =
|
||||
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
|
||||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
|
||||
if (expiringSoon) expiring.push(stats.email);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const client of clients) deactive.push(client.email);
|
||||
}
|
||||
|
||||
return {
|
||||
clients: clients.length,
|
||||
active,
|
||||
deactive,
|
||||
depleted,
|
||||
expiring,
|
||||
online,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
function setInbounds(rows) {
|
||||
const next = [];
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
const dbInbound = new DBInbound(row);
|
||||
const parsed = dbInbound.toInbound();
|
||||
next.push(dbInbound);
|
||||
const tracked = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
if (tracked.includes(row.protocol)) {
|
||||
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[row.id] = rollupClients(dbInbound, parsed);
|
||||
}
|
||||
}
|
||||
dbInbounds.value = next;
|
||||
clientCount.value = counts;
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
async function fetchOnlineUsers() {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||
if (msg?.success) onlineClients.value = msg.obj || [];
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap() {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
|
||||
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
||||
}
|
||||
|
||||
async function fetchDefaultSettings() {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||
tgBotEnable.value = !!s.tgBotEnable;
|
||||
subSettings.value = {
|
||||
enable: !!s.subEnable,
|
||||
subTitle: s.subTitle || '',
|
||||
subURI: s.subURI || '',
|
||||
subJsonURI: s.subJsonURI || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
};
|
||||
pageSize.value = s.pageSize ?? 0;
|
||||
remarkModel.value = s.remarkModel || '-ieo';
|
||||
datepicker.value = s.datepicker || 'gregorian';
|
||||
// Mirror into the global composable so date-pickers in modals can
|
||||
// pick the right calendar without re-fetching the settings.
|
||||
setDatepicker(datepicker.value);
|
||||
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||
}
|
||||
|
||||
// ============ WebSocket live-update merge ===========================
|
||||
// The xray traffic job and the node traffic sync job each broadcast
|
||||
// a `traffic` payload every ~10s. We merge it into onlineClients +
|
||||
// lastOnlineMap; per-inbound counters arrive in the parallel
|
||||
// client_stats event below.
|
||||
function applyTrafficEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (Array.isArray(payload.onlineClients)) {
|
||||
onlineClients.value = payload.onlineClients;
|
||||
}
|
||||
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||
// Merge so a subsequent payload that drops a quiet client doesn't
|
||||
// wipe their last-seen timestamp.
|
||||
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
|
||||
}
|
||||
// Recompute per-inbound rollups so the "online" badges in the
|
||||
// expand-row table flip without waiting for a full refresh.
|
||||
rebuildClientCount();
|
||||
}
|
||||
|
||||
// The client_stats payload carries absolute traffic counters for every
|
||||
// client + per-inbound totals (full snapshot, not deltas). Both are
|
||||
// overwritten in place.
|
||||
function applyClientStatsEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
let touched = false;
|
||||
|
||||
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
|
||||
const byId = new Map();
|
||||
for (const row of payload.inbounds) {
|
||||
if (row && row.id != null) byId.set(row.id, row);
|
||||
}
|
||||
for (const ib of dbInbounds.value) {
|
||||
const upd = byId.get(ib.id);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') ib.up = upd.up;
|
||||
if (typeof upd.down === 'number') ib.down = upd.down;
|
||||
if (typeof upd.total === 'number') ib.total = upd.total;
|
||||
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
|
||||
const byEmail = new Map();
|
||||
for (const row of payload.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
for (const ib of dbInbounds.value) {
|
||||
if (!Array.isArray(ib.clientStats)) continue;
|
||||
for (let i = 0; i < ib.clientStats.length; i++) {
|
||||
const stat = ib.clientStats[i];
|
||||
const upd = byEmail.get(stat.email);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') stat.up = upd.up;
|
||||
if (typeof upd.down === 'number') stat.down = upd.down;
|
||||
if (typeof upd.total === 'number') stat.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
statsVersion.value++;
|
||||
dbInbounds.value = [...dbInbounds.value];
|
||||
rebuildClientCount();
|
||||
}
|
||||
}
|
||||
|
||||
// The hub may decide a payload is too large to push directly and emit
|
||||
// an `invalidate` event with the affected dataType instead. For the
|
||||
// inbounds page that means "the inbound list changed elsewhere — go
|
||||
// re-fetch via REST".
|
||||
function applyInvalidate(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (payload.type === 'inbounds') {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function applyInboundsEvent(payload) {
|
||||
if (!Array.isArray(payload)) return;
|
||||
setInbounds(payload);
|
||||
}
|
||||
|
||||
// Recompute the per-inbound roll-up after any in-place mutation.
|
||||
// Cheap because rollupClients only iterates a single inbound's
|
||||
// clients + clientStats arrays.
|
||||
function rebuildClientCount() {
|
||||
const counts = {};
|
||||
const tracked = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
for (const dbInbound of dbInbounds.value) {
|
||||
const parsed = dbInbound.toInbound();
|
||||
if (!tracked.includes(dbInbound.protocol)) continue;
|
||||
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
|
||||
}
|
||||
clientCount.value = counts;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
if (!msg?.success) return;
|
||||
await fetchLastOnlineMap();
|
||||
await fetchOnlineUsers();
|
||||
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
} finally {
|
||||
// Match legacy: keep the spinning-icon state visible briefly so
|
||||
// a fast network doesn't make the button feel like it didn't fire.
|
||||
setTimeout(() => { refreshing.value = false; }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const totals = computed(() => {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
for (const ib of dbInbounds.value) {
|
||||
up += ib.up || 0;
|
||||
down += ib.down || 0;
|
||||
}
|
||||
return { up, down };
|
||||
});
|
||||
|
||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||
// here so the linter doesn't drop it; the legacy search uses it.
|
||||
void ObjectUtil;
|
||||
|
||||
return {
|
||||
fetched,
|
||||
refreshing,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
subSettings,
|
||||
remarkModel,
|
||||
datepicker,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
pageSize,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
|
||||
export interface SubSettings {
|
||||
enable: boolean;
|
||||
subTitle: string;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
}
|
||||
|
||||
type DBInboundInstance = InstanceType<typeof DBInbound>;
|
||||
|
||||
interface ClientRollup {
|
||||
clients: number;
|
||||
active: string[];
|
||||
deactive: string[];
|
||||
depleted: string[];
|
||||
expiring: string[];
|
||||
online: string[];
|
||||
comments: Map<string, string>;
|
||||
}
|
||||
|
||||
const TRACKED_PROTOCOLS = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
|
||||
export function useInbounds() {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const refreshingRef = useRef(false);
|
||||
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
|
||||
const dbInboundsRef = useRef<DBInboundInstance[]>([]);
|
||||
dbInboundsRef.current = dbInbounds;
|
||||
|
||||
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
|
||||
const [onlineClients, setOnlineClients] = useState<string[]>([]);
|
||||
const onlineClientsRef = useRef<string[]>([]);
|
||||
onlineClientsRef.current = onlineClients;
|
||||
|
||||
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
||||
const [statsVersion, setStatsVersion] = useState(0);
|
||||
|
||||
const [expireDiff, setExpireDiff] = useState(0);
|
||||
const expireDiffRef = useRef(0);
|
||||
expireDiffRef.current = expireDiff;
|
||||
const [trafficDiff, setTrafficDiff] = useState(0);
|
||||
const trafficDiffRef = useRef(0);
|
||||
trafficDiffRef.current = trafficDiff;
|
||||
|
||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||
enable: false,
|
||||
subTitle: '',
|
||||
subURI: '',
|
||||
subJsonURI: '',
|
||||
subJsonEnable: false,
|
||||
});
|
||||
const [remarkModel, setRemarkModel] = useState('-ieo');
|
||||
const [datepicker, setDatepickerState] = useState('gregorian');
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(0);
|
||||
|
||||
const rollupClients = useCallback(
|
||||
(dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
|
||||
const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
|
||||
? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
|
||||
: [];
|
||||
const allClients = inbound?.clients || [];
|
||||
const statsEmails = new Set<string>();
|
||||
for (const s of clientStats) {
|
||||
if (s && s.email) statsEmails.add(s.email);
|
||||
}
|
||||
const clients = clientStats.length > 0
|
||||
? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
|
||||
: allClients;
|
||||
const active: string[] = [];
|
||||
const deactive: string[] = [];
|
||||
const depleted: string[] = [];
|
||||
const expiring: string[] = [];
|
||||
const online: string[] = [];
|
||||
const comments = new Map<string, string>();
|
||||
const now = Date.now();
|
||||
|
||||
if (dbInbound.enable) {
|
||||
for (const client of clients) {
|
||||
if (client.comment && client.email) comments.set(client.email, client.comment);
|
||||
if (client.enable) {
|
||||
if (client.email) active.push(client.email);
|
||||
if (client.email && onlineClientsRef.current.includes(client.email)) online.push(client.email);
|
||||
} else if (client.email) {
|
||||
deactive.push(client.email);
|
||||
}
|
||||
}
|
||||
for (const stats of clientStats) {
|
||||
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
|
||||
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
|
||||
if (expired || exhausted) {
|
||||
depleted.push(stats.email);
|
||||
} else {
|
||||
const expiringSoon =
|
||||
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) ||
|
||||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
|
||||
if (expiringSoon) expiring.push(stats.email);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const client of clients) {
|
||||
if (client.email) deactive.push(client.email);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clients: clients.length,
|
||||
active,
|
||||
deactive,
|
||||
depleted,
|
||||
expiring,
|
||||
online,
|
||||
comments,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setInbounds = useCallback(
|
||||
(rows: unknown[]) => {
|
||||
const next: DBInboundInstance[] = [];
|
||||
const counts: Record<number, ClientRollup> = {};
|
||||
for (const row of rows as { protocol: string; id: number }[]) {
|
||||
const dbInbound = new DBInbound(row) as DBInboundInstance;
|
||||
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
|
||||
next.push(dbInbound);
|
||||
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
|
||||
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
|
||||
}
|
||||
}
|
||||
dbInboundsRef.current = next;
|
||||
setDbInbounds(next);
|
||||
setClientCount(counts);
|
||||
setFetched(true);
|
||||
},
|
||||
[rollupClients],
|
||||
);
|
||||
|
||||
const rebuildClientCount = useCallback(() => {
|
||||
const counts: Record<number, ClientRollup> = {};
|
||||
for (const dbInbound of dbInboundsRef.current) {
|
||||
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean }; isSS: boolean; protocol: string }).toInbound();
|
||||
const protocol = (dbInbound as unknown as { protocol: string }).protocol;
|
||||
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
|
||||
const isSS = (dbInbound as unknown as { isSS: boolean }).isSS;
|
||||
if (isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
|
||||
}
|
||||
setClientCount(counts);
|
||||
}, [rollupClients]);
|
||||
|
||||
const fetchOnlineUsers = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||
if (msg?.success) {
|
||||
const list = (msg.obj || []) as string[];
|
||||
onlineClientsRef.current = list;
|
||||
setOnlineClients(list);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLastOnlineMap = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
|
||||
if (msg?.success && msg.obj) {
|
||||
setLastOnlineMap(msg.obj as Record<string, number>);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDefaultSettings = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (!msg?.success) return;
|
||||
const s = (msg.obj || {}) as Record<string, unknown>;
|
||||
setExpireDiff((s.expireDiff as number ?? 0) * 86400000);
|
||||
setTrafficDiff((s.trafficDiff as number ?? 0) * 1073741824);
|
||||
setTgBotEnable(!!s.tgBotEnable);
|
||||
setSubSettings({
|
||||
enable: !!s.subEnable,
|
||||
subTitle: (s.subTitle as string) || '',
|
||||
subURI: (s.subURI as string) || '',
|
||||
subJsonURI: (s.subJsonURI as string) || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
});
|
||||
setPageSize((s.pageSize as number) ?? 0);
|
||||
setRemarkModel((s.remarkModel as string) || '-ieo');
|
||||
const dp = ((s.datepicker as string) || 'gregorian') as 'gregorian' | 'jalalian';
|
||||
setDatepickerState(dp);
|
||||
setDatepicker(dp);
|
||||
setIpLimitEnable(!!s.ipLimitEnable);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshingRef.current) return;
|
||||
refreshingRef.current = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
if (!msg?.success) return;
|
||||
await fetchLastOnlineMap();
|
||||
await fetchOnlineUsers();
|
||||
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
} finally {
|
||||
window.setTimeout(() => { refreshingRef.current = false; }, 500);
|
||||
}
|
||||
}, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]);
|
||||
|
||||
const applyTrafficEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { onlineClients?: string[]; lastOnlineMap?: Record<string, number> };
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
onlineClientsRef.current = p.onlineClients;
|
||||
setOnlineClients(p.onlineClients);
|
||||
}
|
||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||
}
|
||||
rebuildClientCount();
|
||||
},
|
||||
[rebuildClientCount],
|
||||
);
|
||||
|
||||
const applyClientStatsEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as {
|
||||
inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
|
||||
clients?: { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }[];
|
||||
};
|
||||
let touched = false;
|
||||
|
||||
if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
|
||||
const byId = new Map<number, { id: number; up?: number; down?: number; total?: number; enable?: boolean }>();
|
||||
for (const row of p.inbounds) {
|
||||
if (row && row.id != null) byId.set(row.id, row);
|
||||
}
|
||||
for (const ib of dbInboundsRef.current) {
|
||||
const upd = byId.get((ib as unknown as { id: number }).id);
|
||||
if (!upd) continue;
|
||||
const ibRec = ib as unknown as { up: number; down: number; total: number; enable: boolean };
|
||||
if (typeof upd.up === 'number') ibRec.up = upd.up;
|
||||
if (typeof upd.down === 'number') ibRec.down = upd.down;
|
||||
if (typeof upd.total === 'number') ibRec.total = upd.total;
|
||||
if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(p.clients) && p.clients.length > 0) {
|
||||
const byEmail = new Map<string, { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }>();
|
||||
for (const row of p.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
for (const ib of dbInboundsRef.current) {
|
||||
const stats = (ib as unknown as { clientStats: { email: string; up: number; down: number; total: number; expiryTime: number; enable: boolean }[] }).clientStats;
|
||||
if (!Array.isArray(stats)) continue;
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const stat = stats[i];
|
||||
const upd = byEmail.get(stat.email);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') stat.up = upd.up;
|
||||
if (typeof upd.down === 'number') stat.down = upd.down;
|
||||
if (typeof upd.total === 'number') stat.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
setStatsVersion((v) => v + 1);
|
||||
setDbInbounds((prev) => {
|
||||
const next = [...prev];
|
||||
dbInboundsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
rebuildClientCount();
|
||||
}
|
||||
},
|
||||
[rebuildClientCount],
|
||||
);
|
||||
|
||||
const applyInvalidate = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { type?: string };
|
||||
if (p.type === 'inbounds') {
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const applyInboundsEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
setInbounds(payload);
|
||||
},
|
||||
[setInbounds],
|
||||
);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
for (const ib of dbInbounds) {
|
||||
const rec = ib as unknown as { up?: number; down?: number };
|
||||
up += rec.up || 0;
|
||||
down += rec.down || 0;
|
||||
}
|
||||
return { up, down };
|
||||
}, [dbInbounds]);
|
||||
|
||||
return {
|
||||
fetched,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
subSettings,
|
||||
remarkModel,
|
||||
datepicker,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
pageSize,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.backup-list {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.backup-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.backup-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import './BackupModal.css';
|
||||
|
||||
interface BusyEvent {
|
||||
busy: boolean;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
interface BackupModalProps {
|
||||
open: boolean;
|
||||
basePath: string;
|
||||
onClose: () => void;
|
||||
onBusy: (e: BusyEvent) => void;
|
||||
}
|
||||
|
||||
export default function BackupModal({ open, basePath: _basePath, onClose, onBusy }: BackupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function exportDb() {
|
||||
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
|
||||
}
|
||||
|
||||
function importDb() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.db';
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const dbFile = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!dbFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('db', dbFile);
|
||||
|
||||
onClose();
|
||||
onBusy({ busy: true, tip: `${t('pages.index.importDatabase')}…` });
|
||||
|
||||
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
if (!upload?.success) {
|
||||
onBusy({ busy: false });
|
||||
return;
|
||||
}
|
||||
|
||||
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
|
||||
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
||||
if (restart?.success) {
|
||||
await PromiseUtil.sleep(5000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
onBusy({ busy: false });
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.index.backupTitle')}
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="backup-list">
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
<div className="backup-title">{t('pages.index.exportDatabase')}</div>
|
||||
<div className="backup-description">{t('pages.index.exportDatabaseDesc')}</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
<div className="backup-title">{t('pages.index.importDatabase')}</div>
|
||||
<div className="backup-description">{t('pages.index.importDatabaseDesc')}</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
basePath: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function exportDb() {
|
||||
// The Go endpoint streams x-ui.db as a download. Setting
|
||||
// window.location triggers a browser download without leaving
|
||||
// the page (the Go side responds with Content-Disposition: attachment).
|
||||
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
|
||||
}
|
||||
|
||||
function importDb() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.db';
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const dbFile = e.target.files?.[0];
|
||||
if (!dbFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('db', dbFile);
|
||||
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
|
||||
|
||||
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
if (!upload?.success) {
|
||||
emit('busy', { busy: false });
|
||||
return;
|
||||
}
|
||||
|
||||
emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
|
||||
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
||||
if (restart?.success) {
|
||||
await PromiseUtil.sleep(5000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-list bordered class="backup-list">
|
||||
<a-list-item class="backup-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ t('pages.index.exportDatabase') }}</template>
|
||||
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
|
||||
</a-list-item-meta>
|
||||
<a-button type="primary" @click="exportDb">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
|
||||
<a-list-item class="backup-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ t('pages.index.importDatabase') }}</template>
|
||||
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
|
||||
</a-list-item-meta>
|
||||
<a-button type="primary" @click="importDb">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, message, Modal, Select } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export interface CustomGeoRecord {
|
||||
id: number;
|
||||
type: 'geosite' | 'geoip';
|
||||
alias: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface CustomGeoFormModalProps {
|
||||
open: boolean;
|
||||
record: CustomGeoRecord | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function CustomGeoFormModal({
|
||||
open,
|
||||
record,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: CustomGeoFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
|
||||
const [alias, setAlias] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const editing = record != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (record) {
|
||||
setType(record.type);
|
||||
setAlias(record.alias);
|
||||
setUrl(record.url);
|
||||
} else {
|
||||
setType('geosite');
|
||||
setAlias('');
|
||||
setUrl('');
|
||||
}
|
||||
}, [open, record]);
|
||||
|
||||
function validate(): boolean {
|
||||
if (!/^[a-z0-9_-]+$/.test(alias || '')) {
|
||||
messageApi.error(t('pages.index.customGeoValidationAlias'));
|
||||
return false;
|
||||
}
|
||||
const u = (url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
messageApi.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
messageApi.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const apiUrl = editing
|
||||
? `/panel/api/custom-geo/update/${record!.id}`
|
||||
: '/panel/api/custom-geo/add';
|
||||
const msg = await HttpUtil.post(apiUrl, { type, alias, url });
|
||||
if (msg?.success) {
|
||||
onSaved();
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
|
||||
confirmLoading={saving}
|
||||
okText={t('pages.index.customGeoModalSave')}
|
||||
cancelText={t('close')}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('pages.index.customGeoType')}>
|
||||
<Select
|
||||
value={type}
|
||||
disabled={editing}
|
||||
onChange={(v) => setType(v)}
|
||||
options={[
|
||||
{ value: 'geosite', label: 'geosite' },
|
||||
{ value: 'geoip', label: 'geoip' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.index.customGeoAlias')}>
|
||||
<Input
|
||||
value={alias}
|
||||
disabled={editing}
|
||||
placeholder={t('pages.index.customGeoAliasPlaceholder')}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.index.customGeoUrl')}>
|
||||
<Input
|
||||
value={url}
|
||||
placeholder="https://"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
// Populate with the record when editing; null/undefined when adding.
|
||||
record: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const form = reactive({ type: 'geosite', alias: '', url: '' });
|
||||
const saving = ref(false);
|
||||
|
||||
const editing = ref(false);
|
||||
const editId = ref(null);
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (props.record) {
|
||||
editing.value = true;
|
||||
editId.value = props.record.id;
|
||||
form.type = props.record.type;
|
||||
form.alias = props.record.alias;
|
||||
form.url = props.record.url;
|
||||
} else {
|
||||
editing.value = false;
|
||||
editId.value = null;
|
||||
form.type = 'geosite';
|
||||
form.alias = '';
|
||||
form.url = '';
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function validate() {
|
||||
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
|
||||
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
|
||||
message.error(t('pages.index.customGeoValidationAlias'));
|
||||
return false;
|
||||
}
|
||||
const u = (form.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const url = editing.value
|
||||
? `/panel/api/custom-geo/update/${editId.value}`
|
||||
: '/panel/api/custom-geo/add';
|
||||
const msg = await HttpUtil.post(url, form);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
|
||||
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
|
||||
@cancel="close">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('pages.index.customGeoType')">
|
||||
<a-select v-model:value="form.type" :disabled="editing">
|
||||
<a-select-option value="geosite">geosite</a-select-option>
|
||||
<a-select-option value="geoip">geoip</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.index.customGeoAlias')">
|
||||
<a-input v-model:value="form.alias" :disabled="editing"
|
||||
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.index.customGeoUrl')">
|
||||
<a-input v-model:value="form.url" placeholder="https://" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-geo-count {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
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;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-geo-alias {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-type-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-geo-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-ext-code {
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
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);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.custom-geo-empty {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.custom-geo-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, message, Modal, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||
import CustomGeoFormModal from './CustomGeoFormModal';
|
||||
import type { CustomGeoRecord } from './CustomGeoFormModal';
|
||||
import './CustomGeoSection.css';
|
||||
|
||||
interface CustomGeoSectionProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface CustomGeoListRecord extends CustomGeoRecord {
|
||||
lastUpdatedAt?: number;
|
||||
}
|
||||
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function relativeTime(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||
return formatTime(ts);
|
||||
}
|
||||
|
||||
function extDisplay(record: CustomGeoListRecord): string {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
}
|
||||
|
||||
export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [list, setList] = useState<CustomGeoListRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingAll, setUpdatingAll] = useState(false);
|
||||
const [actionId, setActionId] = useState<number | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<CustomGeoListRecord | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) setList(msg.obj);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) loadList();
|
||||
}, [active, loadList]);
|
||||
|
||||
function openAdd() {
|
||||
setEditingRecord(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(record: CustomGeoListRecord) {
|
||||
setEditingRecord(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function copyExt(record: CustomGeoListRecord) {
|
||||
const text = extDisplay(record);
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) messageApi.success(`${t('copied')}: ${text}`);
|
||||
}
|
||||
|
||||
function confirmDelete(record: CustomGeoListRecord) {
|
||||
modal.confirm({
|
||||
title: t('pages.index.customGeoDelete'),
|
||||
content: t('pages.index.customGeoDeleteConfirm'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg?.success) await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadOne(id: number) {
|
||||
setActionId(id);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg?.success) await loadList();
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
setUpdatingAll(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||
const ok = msg?.obj?.succeeded?.length || 0;
|
||||
const failed = msg?.obj?.failed?.length || 0;
|
||||
if (msg?.success || ok > 0) {
|
||||
await loadList();
|
||||
if (failed > 0) messageApi.warning(`Updated ${ok}, failed ${failed}`);
|
||||
}
|
||||
} finally {
|
||||
setUpdatingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnsType<CustomGeoListRecord>>(
|
||||
() => [
|
||||
{
|
||||
title: t('pages.index.customGeoAlias'),
|
||||
key: 'alias',
|
||||
width: 200,
|
||||
render: (_v, record) => (
|
||||
<div className="custom-geo-alias-cell">
|
||||
<Tag color={record.type === 'geoip' ? 'cyan' : 'purple'} className="custom-geo-type-tag">
|
||||
{record.type}
|
||||
</Tag>
|
||||
<span className="custom-geo-alias">{record.alias}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoUrl'),
|
||||
key: 'url',
|
||||
ellipsis: true,
|
||||
render: (_v, record) => (
|
||||
<Tooltip placement="topLeft" title={record.url}>
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="custom-geo-url"
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoExtColumn'),
|
||||
key: 'extDat',
|
||||
width: 220,
|
||||
render: (_v, record) => (
|
||||
<Tooltip title={t('copy')}>
|
||||
<code
|
||||
className="custom-geo-ext-code custom-geo-copyable"
|
||||
onClick={() => copyExt(record)}
|
||||
>
|
||||
{extDisplay(record)}
|
||||
</code>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoLastUpdated'),
|
||||
key: 'lastUpdatedAt',
|
||||
width: 140,
|
||||
render: (_v, record) =>
|
||||
record.lastUpdatedAt ? (
|
||||
<Tooltip title={formatTime(record.lastUpdatedAt)}>
|
||||
<span>{relativeTime(record.lastUpdatedAt)}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="custom-geo-muted">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoActions'),
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_v, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title={t('pages.index.customGeoEdit')}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.index.customGeoDownload')}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={actionId === record.id}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => downloadOne(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.index.customGeoDelete')}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, actionId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="custom-geo-section">
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-10"
|
||||
title={t('pages.index.customGeoRoutingHint')}
|
||||
/>
|
||||
|
||||
<div className="toolbar">
|
||||
<Button type="primary" loading={loading} onClick={openAdd} icon={<PlusOutlined />}>
|
||||
{t('pages.index.customGeoAdd')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={updatingAll}
|
||||
disabled={list.length === 0}
|
||||
onClick={updateAll}
|
||||
icon={<ReloadOutlined />}
|
||||
>
|
||||
{t('pages.index.geofilesUpdateAll')}
|
||||
</Button>
|
||||
{list.length > 0 && <span className="custom-geo-count">{list.length}</span>}
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
pagination={false}
|
||||
rowKey={(r) => r.id}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 760 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="custom-geo-empty">
|
||||
<InboxOutlined className="custom-geo-empty-icon" />
|
||||
<div>{t('pages.index.customGeoEmpty')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<CustomGeoFormModal
|
||||
open={formOpen}
|
||||
record={editingRecord}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSaved={loadList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||
import CustomGeoFormModal from './CustomGeoFormModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
// Re-fetch the list when the parent collapse expands this section.
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
const updatingAll = ref(false);
|
||||
const actionId = ref(null);
|
||||
|
||||
const formOpen = ref(false);
|
||||
const editingRecord = ref(null);
|
||||
|
||||
// Computed so column titles re-render after a locale swap.
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
|
||||
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
|
||||
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
|
||||
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
|
||||
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
|
||||
]);
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingRecord.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
editingRecord.value = record;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function extDisplay(record) {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
}
|
||||
|
||||
async function copyExt(record) {
|
||||
const text = extDisplay(record);
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) message.success(`${t('copied')}: ${text}`);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Tiny inline relative-time formatter so we don't pull in moment.
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||
return formatTime(ts);
|
||||
}
|
||||
|
||||
function confirmDelete(record) {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.customGeoDelete'),
|
||||
content: t('pages.index.customGeoDeleteConfirm'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg?.success) await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadOne(id) {
|
||||
actionId.value = id;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg?.success) await loadList();
|
||||
} finally {
|
||||
actionId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
updatingAll.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||
const ok = msg?.obj?.succeeded?.length || 0;
|
||||
const failed = msg?.obj?.failed?.length || 0;
|
||||
if (msg?.success || ok > 0) {
|
||||
await loadList();
|
||||
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
||||
}
|
||||
} finally {
|
||||
updatingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load: only fetch when the parent collapse opens this panel.
|
||||
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-geo-section">
|
||||
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
|
||||
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" :loading="loading" @click="openAdd">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.customGeoAdd') }}
|
||||
</a-button>
|
||||
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.geofilesUpdateAll') }}
|
||||
</a-button>
|
||||
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
|
||||
size="small" :scroll="{ x: 760 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'alias'">
|
||||
<div class="custom-geo-alias-cell">
|
||||
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
|
||||
{{ record.type }}
|
||||
</a-tag>
|
||||
<span class="custom-geo-alias">{{ record.alias }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'url'">
|
||||
<a-tooltip placement="topLeft" :title="record.url">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
|
||||
{{ record.url }}
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'extDat'">
|
||||
<a-tooltip :title="t('copy')">
|
||||
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
|
||||
{{ extDisplay(record) }}
|
||||
</code>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastUpdatedAt'">
|
||||
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
|
||||
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
|
||||
</a-tooltip>
|
||||
<span v-else class="custom-geo-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space size="small">
|
||||
<a-tooltip :title="t('pages.index.customGeoEdit')">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.index.customGeoDownload')">
|
||||
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.index.customGeoDelete')">
|
||||
<a-button type="link" size="small" danger @click="confirmDelete(record)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<div class="custom-geo-empty">
|
||||
<InboxOutlined class="custom-geo-empty-icon" />
|
||||
<div>{{ t('pages.index.customGeoEmpty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-geo-count {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-geo-alias-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-geo-alias {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-type-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-geo-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-ext-code {
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.custom-geo-copyable:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-ext-code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-copyable:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.custom-geo-empty {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.custom-geo-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
.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;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.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) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.index-page .action-update .anticon {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.index-page .history-tag {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.index-page .tg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.index-page .ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.index-page .ip-hidden .ant-statistic-content-value {
|
||||
filter: blur(6px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.index-page .ip-visible .ant-statistic-content-value {
|
||||
filter: none;
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Layout,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
BarsOutlined,
|
||||
ControlOutlined,
|
||||
CloudServerOutlined,
|
||||
CloudDownloadOutlined,
|
||||
CloudUploadOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
AreaChartOutlined,
|
||||
GlobalOutlined,
|
||||
SwapOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
ThunderboltOutlined,
|
||||
DesktopOutlined,
|
||||
DatabaseOutlined,
|
||||
ForkOutlined,
|
||||
CopyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useStatus } from '@/hooks/useStatus';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
import JsonEditor from '@/components/JsonEditor';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import StatusCard from './StatusCard';
|
||||
import XrayStatusCard from './XrayStatusCard';
|
||||
import PanelUpdateModal from './PanelUpdateModal';
|
||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||
import LogModal from './LogModal';
|
||||
import BackupModal from './BackupModal';
|
||||
import SystemHistoryModal from './SystemHistoryModal';
|
||||
import XrayMetricsModal from './XrayMetricsModal';
|
||||
import XrayLogModal from './XrayLogModal';
|
||||
import VersionModal from './VersionModal';
|
||||
import '@/styles/page-cards.css';
|
||||
import './IndexPage.css';
|
||||
|
||||
export default function IndexPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { status, fetched, refresh } = useStatus();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||
currentVersion: '',
|
||||
latestVersion: '',
|
||||
updateAvailable: false,
|
||||
});
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [backupOpen, setBackupOpen] = useState(false);
|
||||
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
||||
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
|
||||
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
|
||||
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
|
||||
const [versionOpen, setVersionOpen] = useState(false);
|
||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
useEffect(() => {
|
||||
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
|
||||
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
|
||||
});
|
||||
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const displayVersion = useMemo(
|
||||
() => panelUpdateInfo.currentVersion || window.X_UI_CUR_VER || '?',
|
||||
[panelUpdateInfo.currentVersion],
|
||||
);
|
||||
|
||||
const setBusy = useCallback(
|
||||
({ busy, tip }: { busy: boolean; tip?: string }) => {
|
||||
setLoading(busy);
|
||||
if (tip) setLoadingTip(tip);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopXray = useCallback(async () => {
|
||||
await HttpUtil.post('/panel/api/server/stopXrayService');
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const restartXray = useCallback(async () => {
|
||||
await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
function openPanelVersion() {
|
||||
if (panelUpdateInfo.updateAvailable) {
|
||||
setPanelUpdateOpen(true);
|
||||
} else {
|
||||
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
function openTelegram() {
|
||||
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function openConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||
if (!msg?.success) return;
|
||||
setConfigText(JSON.stringify(msg.obj, null, 2));
|
||||
setConfigTextOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
const ok = await ClipboardManager.copyText(configText || '');
|
||||
if (ok) messageApi.success('Copied');
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
FileManager.downloadTextFile(configText, 'config.json');
|
||||
}
|
||||
|
||||
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<Spin
|
||||
spinning={loading || !fetched}
|
||||
delay={200}
|
||||
description={loading ? loadingTip : t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
||||
<Col span={24}>
|
||||
<StatusCard status={status} isMobile={isMobile} />
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<XrayStatusCard
|
||||
status={status}
|
||||
isMobile={isMobile}
|
||||
ipLimitEnable={ipLimitEnable}
|
||||
onStopXray={stopXray}
|
||||
onRestartXray={restartXray}
|
||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||
onOpenLogs={() => setLogsOpen(true)}
|
||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={t('menu.link')}
|
||||
hoverable
|
||||
actions={[
|
||||
<Space className="action" key="logs" onClick={() => setLogsOpen(true)}>
|
||||
<BarsOutlined />
|
||||
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="config" onClick={openConfig}>
|
||||
<ControlOutlined />
|
||||
{!isMobile && <span>{t('pages.index.config')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="backup" onClick={() => setBackupOpen(true)}>
|
||||
<CloudServerOutlined />
|
||||
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
|
||||
</Space>,
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<span>3X-UI</span>
|
||||
{isMobile && displayVersion && (
|
||||
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
|
||||
{panelUpdateInfo.updateAvailable
|
||||
? `v${panelUpdateInfo.latestVersion}`
|
||||
: `v${displayVersion}`}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
hoverable
|
||||
actions={[
|
||||
<Space className="action" key="tg" onClick={openTelegram}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
className="tg-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
|
||||
</svg>
|
||||
{!isMobile && <span>@XrayUI</span>}
|
||||
</Space>,
|
||||
<Space
|
||||
key="panel-version"
|
||||
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
|
||||
onClick={openPanelVersion}
|
||||
>
|
||||
<CloudDownloadOutlined />
|
||||
{!isMobile && (
|
||||
<span>
|
||||
{panelUpdateInfo.updateAvailable
|
||||
? `${t('update')} ${panelUpdateInfo.latestVersion}`
|
||||
: `v${displayVersion}`}
|
||||
</span>
|
||||
)}
|
||||
</Space>,
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={t('pages.index.charts')}
|
||||
hoverable
|
||||
actions={[
|
||||
<Space
|
||||
className="action"
|
||||
key="sys-history"
|
||||
onClick={() => setSysHistoryOpen(true)}
|
||||
>
|
||||
<AreaChartOutlined />
|
||||
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
|
||||
</Space>,
|
||||
<Space
|
||||
className="action"
|
||||
key="xray-metrics"
|
||||
onClick={() => setXrayMetricsOpen(true)}
|
||||
>
|
||||
<AreaChartOutlined />
|
||||
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
|
||||
</Space>,
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('pages.index.operationHours')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title="Xray"
|
||||
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title="OS"
|
||||
value={TimeFormatter.formatSecond(status.uptime)}
|
||||
prefix={<DesktopOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('usage')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title={t('pages.index.memory')}
|
||||
value={SizeFormatter.sizeFormat(status.appStats.mem)}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title={t('pages.index.threads')}
|
||||
value={status.appStats.threads}
|
||||
prefix={<ForkOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('pages.index.overallSpeed')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title={t('pages.index.upload')}
|
||||
value={SizeFormatter.sizeFormat(status.netIO.up)}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
suffix="/s"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title={t('pages.index.download')}
|
||||
value={SizeFormatter.sizeFormat(status.netIO.down)}
|
||||
prefix={<ArrowDownOutlined />}
|
||||
suffix="/s"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('pages.index.totalData')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title={t('pages.index.sent')}
|
||||
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
|
||||
prefix={<CloudUploadOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title={t('pages.index.received')}
|
||||
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
|
||||
prefix={<CloudDownloadOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={t('pages.index.ipAddresses')}
|
||||
hoverable
|
||||
extra={
|
||||
<Tooltip
|
||||
title={t('pages.index.toggleIpVisibility')}
|
||||
placement={isMobile ? 'topRight' : 'top'}
|
||||
>
|
||||
{showIp ? (
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
onClick={() => setShowIp(false)}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
onClick={() => setShowIp(true)}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={isMobile ? 24 : 12}>
|
||||
<CustomStatistic
|
||||
title="IPv4"
|
||||
value={status.publicIP.ipv4}
|
||||
prefix={<GlobalOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={isMobile ? 24 : 12}>
|
||||
<CustomStatistic
|
||||
title="IPv6"
|
||||
value={status.publicIP.ipv6}
|
||||
prefix={<GlobalOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('pages.index.connectionCount')} hoverable>
|
||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title="TCP"
|
||||
value={status.tcpCount}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<CustomStatistic
|
||||
title="UDP"
|
||||
value={status.udpCount}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<PanelUpdateModal
|
||||
open={panelUpdateOpen}
|
||||
info={panelUpdateInfo}
|
||||
onClose={() => setPanelUpdateOpen(false)}
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
|
||||
<BackupModal
|
||||
open={backupOpen}
|
||||
basePath={basePath}
|
||||
onClose={() => setBackupOpen(false)}
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
<SystemHistoryModal
|
||||
open={sysHistoryOpen}
|
||||
status={status}
|
||||
onClose={() => setSysHistoryOpen(false)}
|
||||
/>
|
||||
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
|
||||
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
|
||||
<VersionModal
|
||||
open={versionOpen}
|
||||
status={status}
|
||||
onClose={() => setVersionOpen(false)}
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={configTextOpen}
|
||||
title={t('pages.index.config')}
|
||||
width={isMobile ? '100%' : 900}
|
||||
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
|
||||
onCancel={() => setConfigTextOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="download"
|
||||
onClick={downloadConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
{isMobile ? 'Download' : 'config.json'}
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
onClick={copyConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CopyOutlined />}
|
||||
>
|
||||
Copy
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<JsonEditor
|
||||
value={configText}
|
||||
onChange={setConfigText}
|
||||
minHeight={isMobile ? '300px' : '420px'}
|
||||
maxHeight={isMobile ? '500px' : '720px'}
|
||||
readOnly
|
||||
/>
|
||||
</Modal>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
BarsOutlined,
|
||||
ControlOutlined,
|
||||
CloudServerOutlined,
|
||||
CloudDownloadOutlined,
|
||||
CloudUploadOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
AreaChartOutlined,
|
||||
GlobalOutlined,
|
||||
SwapOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
ThunderboltOutlined,
|
||||
DesktopOutlined,
|
||||
DatabaseOutlined,
|
||||
ForkOutlined,
|
||||
CopyOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useStatus } from '@/composables/useStatus.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import JsonEditor from '@/components/JsonEditor.vue';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
import XrayStatusCard from './XrayStatusCard.vue';
|
||||
import PanelUpdateModal from './PanelUpdateModal.vue';
|
||||
import LogModal from './LogModal.vue';
|
||||
import BackupModal from './BackupModal.vue';
|
||||
import SystemHistoryModal from './SystemHistoryModal.vue';
|
||||
import XrayMetricsModal from './XrayMetricsModal.vue';
|
||||
import XrayLogModal from './XrayLogModal.vue';
|
||||
import VersionModal from './VersionModal.vue';
|
||||
|
||||
const { status, fetched, refresh } = useStatus();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
// `/panel/setting/defaultSettings` returns ipLimitEnable; the xray
|
||||
// card hides its log button when access logs are off.
|
||||
const ipLimitEnable = ref(false);
|
||||
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
|
||||
if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
|
||||
});
|
||||
|
||||
// Panel-update info — fetched once on mount, drives both the badge
|
||||
// in QuickActions and the contents of PanelUpdateModal.
|
||||
const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
|
||||
onMounted(() => {
|
||||
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||
if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
|
||||
});
|
||||
});
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// In production, dist.go injects window.X_UI_CUR_VER at serve time.
|
||||
// In dev, Vite serves the HTML directly so the global is missing — fall
|
||||
// back to currentVersion from the panel-update API once it answers.
|
||||
const displayVersion = computed(
|
||||
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
||||
);
|
||||
|
||||
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
||||
const showIp = ref(false);
|
||||
|
||||
// Modal open state.
|
||||
const logsOpen = ref(false);
|
||||
const backupOpen = ref(false);
|
||||
const panelUpdateOpen = ref(false);
|
||||
const sysHistoryOpen = ref(false);
|
||||
const xrayMetricsOpen = ref(false);
|
||||
const xrayLogsOpen = ref(false);
|
||||
const versionOpen = ref(false);
|
||||
const configTextOpen = ref(false);
|
||||
const configText = ref('');
|
||||
|
||||
// Page-level loading overlay; modals can request it via @busy.
|
||||
const loading = ref(false);
|
||||
const loadingTip = ref(t('loading'));
|
||||
function setBusy({ busy, tip }) {
|
||||
loading.value = busy;
|
||||
if (tip) loadingTip.value = tip;
|
||||
}
|
||||
|
||||
// Xray controls
|
||||
async function stopXray() {
|
||||
await HttpUtil.post('/panel/api/server/stopXrayService');
|
||||
await refresh();
|
||||
}
|
||||
async function restartXray() {
|
||||
await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function openSystemHistory() { sysHistoryOpen.value = true; }
|
||||
function openXrayLogs() { xrayLogsOpen.value = true; }
|
||||
function openVersionSwitch() { versionOpen.value = true; }
|
||||
|
||||
function openPanelVersion() {
|
||||
if (panelUpdateInfo.value.updateAvailable) {
|
||||
panelUpdateOpen.value = true;
|
||||
} else {
|
||||
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
function openTelegram() {
|
||||
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
// Legacy "Config" action — fetch the rendered xray config and show
|
||||
// it as JSON in the config modal with syntax highlighting.
|
||||
async function openConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||
if (!msg?.success) return;
|
||||
configText.value = JSON.stringify(msg.obj, null, 2);
|
||||
configTextOpen.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
const ok = await ClipboardManager.copyText(configText.value || '');
|
||||
if (ok) {
|
||||
message.success('Copied');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
FileManager.downloadTextFile(configText.value, 'config.json');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="index-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||
<a-col :span="24">
|
||||
<StatusCard :status="status" :is-mobile="isMobile" />
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
|
||||
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
|
||||
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('menu.link')" hoverable>
|
||||
<template #actions>
|
||||
<a-space class="action" @click="logsOpen = true">
|
||||
<BarsOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
||||
</a-space>
|
||||
<a-space class="action" @click="openConfig">
|
||||
<ControlOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.config') }}</span>
|
||||
</a-space>
|
||||
<a-space class="action" @click="backupOpen = true">
|
||||
<CloudServerOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="3X-UI" hoverable>
|
||||
<template #actions>
|
||||
<a-space class="action" @click="openTelegram">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
|
||||
</svg>
|
||||
<span v-if="!isMobile">@XrayUI</span>
|
||||
</a-space>
|
||||
<a-space class="action" :class="{ 'action-update': panelUpdateInfo.updateAvailable }"
|
||||
@click="openPanelVersion">
|
||||
<CloudDownloadOutlined />
|
||||
<span v-if="!isMobile">
|
||||
{{ panelUpdateInfo.updateAvailable
|
||||
? `${t('update')} ${panelUpdateInfo.latestVersion}`
|
||||
: `v${displayVersion}` }}
|
||||
</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.charts')" hoverable>
|
||||
<template #actions>
|
||||
<a-space class="action" @click="openSystemHistory">
|
||||
<AreaChartOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.systemHistoryTitle') }}</span>
|
||||
</a-space>
|
||||
<a-space class="action" @click="xrayMetricsOpen = true">
|
||||
<AreaChartOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.xrayMetricsTitle') }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.operationHours')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
|
||||
<template #prefix>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
|
||||
<template #prefix>
|
||||
<DesktopOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('usage')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.memory')"
|
||||
:value="SizeFormatter.sizeFormat(status.appStats.mem)">
|
||||
<template #prefix>
|
||||
<DatabaseOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.threads')" :value="status.appStats.threads">
|
||||
<template #prefix>
|
||||
<ForkOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.overallSpeed')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.upload')"
|
||||
:value="SizeFormatter.sizeFormat(status.netIO.up)">
|
||||
<template #prefix>
|
||||
<ArrowUpOutlined />
|
||||
</template>
|
||||
<template #suffix>/s</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.download')"
|
||||
:value="SizeFormatter.sizeFormat(status.netIO.down)">
|
||||
<template #prefix>
|
||||
<ArrowDownOutlined />
|
||||
</template>
|
||||
<template #suffix>/s</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.totalData')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.sent')"
|
||||
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
|
||||
<template #prefix>
|
||||
<CloudUploadOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.received')"
|
||||
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
|
||||
<template #prefix>
|
||||
<CloudDownloadOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.ipAddresses')" hoverable>
|
||||
<template #extra>
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
|
||||
<component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showIp = !showIp" />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="isMobile ? 24 : 12">
|
||||
<CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
|
||||
<template #prefix>
|
||||
<GlobalOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="isMobile ? 24 : 12">
|
||||
<CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
|
||||
<template #prefix>
|
||||
<GlobalOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.connectionCount')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="TCP" :value="status.tcpCount">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="UDP" :value="status.udpCount">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
|
||||
<LogModal v-model:open="logsOpen" />
|
||||
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
||||
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
||||
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
|
||||
<XrayLogModal v-model:open="xrayLogsOpen" />
|
||||
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
||||
|
||||
<a-modal v-model:open="configTextOpen" :title="t('pages.index.config')" :width="isMobile ? '100%' : '900px'"
|
||||
:style="isMobile ? { top: '20px', maxWidth: 'calc(100vw - 16px)' } : {}" :closable="true">
|
||||
<JsonEditor v-model:value="configText" :min-height="isMobile ? '300px' : '420px'"
|
||||
:max-height="isMobile ? '500px' : '720px'" :readonly="true" />
|
||||
<template #footer>
|
||||
<a-button @click="downloadConfig" :size="isMobile ? 'small' : 'middle'">
|
||||
<template #icon>
|
||||
<CloudDownloadOutlined />
|
||||
</template>
|
||||
<span v-if="!isMobile">config.json</span>
|
||||
<span v-else>Download</span>
|
||||
</a-button>
|
||||
<a-button type="primary" @click="copyConfig" :size="isMobile ? 'small' : 'middle'">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.index-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.index-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.index-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.index-page :deep(.ant-layout),
|
||||
.index-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.action {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-update {
|
||||
color: #fa8c16;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-update :deep(.anticon) {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.tg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ip-hidden :deep(.ant-statistic-content-value) {
|
||||
filter: blur(6px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.ip-visible :deep(.ant-statistic-content-value) {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
--log-stamp: #3c89e8;
|
||||
--log-debug: #3c89e8;
|
||||
--log-info: #008771;
|
||||
--log-notice: #008771;
|
||||
--log-warning: #f37b24;
|
||||
--log-error: #e04141;
|
||||
--log-unknown: #595959;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.log-stamp {
|
||||
color: var(--log-stamp);
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
color: var(--log-debug);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
color: var(--log-info);
|
||||
}
|
||||
|
||||
.level-notice {
|
||||
color: var(--log-notice);
|
||||
}
|
||||
|
||||
.level-warning {
|
||||
color: var(--log-warning);
|
||||
}
|
||||
|
||||
.level-error {
|
||||
color: var(--log-error);
|
||||
}
|
||||
|
||||
.level-unknown {
|
||||
color: var(--log-unknown);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
white-space: normal;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-line + .log-line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.log-level-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.log-body {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-body-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
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;
|
||||
--log-notice: #4ed3a6;
|
||||
--log-warning: #ffb872;
|
||||
--log-error: #ff7575;
|
||||
--log-unknown: #b5b5b5;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .log-container {
|
||||
--log-stamp: #7fb6f1;
|
||||
--log-debug: #7fb6f1;
|
||||
--log-info: #5fd9b0;
|
||||
--log-notice: #5fd9b0;
|
||||
--log-warning: #ffcc88;
|
||||
--log-error: #ff8a8a;
|
||||
--log-unknown: #c4c4c4;
|
||||
--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;
|
||||
}
|
||||
|
||||
.logmodal-mobile .ant-modal-body {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import './LogModal.css';
|
||||
|
||||
interface LogModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ParsedLog {
|
||||
date: string;
|
||||
time: string;
|
||||
stamp: string;
|
||||
levelText: string;
|
||||
levelClass: string;
|
||||
service: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
||||
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
|
||||
|
||||
function parseLogLine(line: string): ParsedLog {
|
||||
const [head, ...rest] = (line || '').split(' - ');
|
||||
const message = rest.join(' - ');
|
||||
const parts = head.split(' ');
|
||||
|
||||
let date = '';
|
||||
let time = '';
|
||||
let levelText: string;
|
||||
if (parts.length >= 3) {
|
||||
[date, time, levelText] = parts;
|
||||
} else {
|
||||
levelText = head;
|
||||
}
|
||||
|
||||
const li = LEVELS.indexOf(levelText);
|
||||
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
||||
|
||||
let service = '';
|
||||
let body = message || '';
|
||||
if (body.startsWith('XRAY:')) {
|
||||
service = 'XRAY:';
|
||||
body = body.slice('XRAY:'.length).trimStart();
|
||||
} else if (body) {
|
||||
service = 'X-UI:';
|
||||
}
|
||||
|
||||
const stamp = [date, time].filter(Boolean).join(' ');
|
||||
|
||||
return { date, time, stamp, levelText, levelClass, service, body };
|
||||
}
|
||||
|
||||
export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [rows, setRows] = useState('20');
|
||||
const [level, setLevel] = useState('info');
|
||||
const [syslog, setSyslog] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const openRef = useRef(open);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/server/logs/${rows}`, {
|
||||
level,
|
||||
syslog,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setLogs(msg.obj || []);
|
||||
}
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rows, level, syslog]);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
if (open) refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openRef.current) refresh();
|
||||
}, [rows, level, syslog, refresh]);
|
||||
|
||||
const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]);
|
||||
|
||||
function download() {
|
||||
FileManager.downloadTextFile(logs.join('\n'), 'x-ui.log');
|
||||
}
|
||||
|
||||
const titleNode = (
|
||||
<>
|
||||
{t('pages.index.logs')}
|
||||
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : 800}
|
||||
className={isMobile ? 'logmodal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={titleNode}
|
||||
>
|
||||
<Form layout="inline" className="log-toolbar">
|
||||
<Form.Item>
|
||||
<Space.Compact>
|
||||
<Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
|
||||
<Select.Option value="10">10</Select.Option>
|
||||
<Select.Option value="20">20</Select.Option>
|
||||
<Select.Option value="50">50</Select.Option>
|
||||
<Select.Option value="100">100</Select.Option>
|
||||
<Select.Option value="500">500</Select.Option>
|
||||
</Select>
|
||||
<Select value={level} size="small" style={{ width: 95 }} onChange={setLevel}>
|
||||
<Select.Option value="debug">Debug</Select.Option>
|
||||
<Select.Option value="info">Info</Select.Option>
|
||||
<Select.Option value="notice">Notice</Select.Option>
|
||||
<Select.Option value="warning">Warning</Select.Option>
|
||||
<Select.Option value="err">Error</Select.Option>
|
||||
</Select>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
|
||||
SysLog
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||
{parsedLogs.length === 0 ? (
|
||||
<div className="log-empty">No Record...</div>
|
||||
) : isMobile ? (
|
||||
parsedLogs.map((log, idx) => (
|
||||
<div key={idx} className="log-card">
|
||||
<div className="log-card-head">
|
||||
{log.stamp && (
|
||||
<span className="log-time">
|
||||
{log.time && <span>{log.time}</span>}
|
||||
{log.time && log.date ? ' ' : ''}
|
||||
{log.date && <span className="log-date">{log.date}</span>}
|
||||
</span>
|
||||
)}
|
||||
{log.levelText && (
|
||||
<span className={`log-level-badge ${log.levelClass}`}>{log.levelText}</span>
|
||||
)}
|
||||
</div>
|
||||
{(log.body || log.service) && (
|
||||
<div className="log-body">
|
||||
{log.service && <b>{log.service}</b>}
|
||||
{log.service && log.body ? ' ' : ''}
|
||||
{log.body && <span className="log-body-text">{log.body}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
parsedLogs.map((log, idx) => (
|
||||
<div key={idx} className="log-line">
|
||||
{log.stamp && <span className="log-stamp">{log.stamp}</span>}
|
||||
{log.stamp && log.levelText ? ' ' : ''}
|
||||
{log.levelText && <span className={`log-level ${log.levelClass}`}>{log.levelText}</span>}
|
||||
{(log.body || log.service) && (
|
||||
<>
|
||||
<span> - </span>
|
||||
{log.service && <b>{log.service}</b>}
|
||||
{log.service && log.body ? ' ' : ''}
|
||||
<span>{log.body}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const rows = ref('20');
|
||||
const level = ref('info');
|
||||
const syslog = ref(false);
|
||||
const loading = ref(false);
|
||||
const logs = ref([]);
|
||||
|
||||
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
||||
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
|
||||
|
||||
// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
|
||||
// 3-token header degrade gracefully: the unparsed head becomes the
|
||||
// level so it still gets color-coded.
|
||||
function parseLogLine(line) {
|
||||
const [head, ...rest] = (line || '').split(' - ');
|
||||
const message = rest.join(' - ');
|
||||
const parts = head.split(' ');
|
||||
|
||||
let date = '';
|
||||
let time = '';
|
||||
let levelText;
|
||||
if (parts.length >= 3) {
|
||||
[date, time, levelText] = parts;
|
||||
} else {
|
||||
levelText = head;
|
||||
}
|
||||
|
||||
const li = LEVELS.indexOf(levelText);
|
||||
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
||||
|
||||
let service = '';
|
||||
let body = message || '';
|
||||
if (body.startsWith('XRAY:')) {
|
||||
service = 'XRAY:';
|
||||
body = body.slice('XRAY:'.length).trimStart();
|
||||
} else if (body) {
|
||||
service = 'X-UI:';
|
||||
}
|
||||
|
||||
const stamp = [date, time].filter(Boolean).join(' ');
|
||||
|
||||
return { date, time, stamp, levelText, levelClass, service, body };
|
||||
}
|
||||
|
||||
const parsedLogs = computed(() => logs.value.map(parseLogLine));
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/server/logs/${rows.value}`, {
|
||||
level: level.value,
|
||||
syslog: syslog.value,
|
||||
});
|
||||
if (msg?.success) {
|
||||
logs.value = msg.obj || [];
|
||||
}
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function download() {
|
||||
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => { if (next) refresh(); });
|
||||
watch([rows, level, syslog], () => { if (props.open) refresh(); });
|
||||
|
||||
const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
|
||||
@cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.logs') }}
|
||||
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
||||
</template>
|
||||
|
||||
<a-form layout="inline" class="log-toolbar">
|
||||
<a-form-item>
|
||||
<a-input-group compact>
|
||||
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
||||
<a-select-option value="10">10</a-select-option>
|
||||
<a-select-option value="20">20</a-select-option>
|
||||
<a-select-option value="50">50</a-select-option>
|
||||
<a-select-option value="100">100</a-select-option>
|
||||
<a-select-option value="500">500</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="level" size="small" :style="{ width: '95px' }">
|
||||
<a-select-option value="debug">Debug</a-select-option>
|
||||
<a-select-option value="info">Info</a-select-option>
|
||||
<a-select-option value="notice">Notice</a-select-option>
|
||||
<a-select-option value="warning">Warning</a-select-option>
|
||||
<a-select-option value="err">Error</a-select-option>
|
||||
</a-select>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item class="download-item">
|
||||
<a-button type="primary" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
||||
<div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
|
||||
|
||||
<template v-else-if="isMobile">
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
||||
<div class="log-card-head">
|
||||
<span v-if="log.stamp" class="log-time">
|
||||
<span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||
</span>
|
||||
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
||||
{{ log.levelText }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="log.body || log.service" class="log-body">
|
||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
||||
<span v-if="log.stamp" class="log-stamp">{{ log.stamp }}</span>{{ log.stamp && log.levelText ? ' ' : '' }}<span v-if="log.levelText" class="log-level" :class="log.levelClass">{{ log.levelText }}</span>
|
||||
<template v-if="log.body || log.service">
|
||||
<span> - </span>
|
||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
||||
below so each level keeps ≥4.5:1 contrast against the container. */
|
||||
--log-stamp: #3c89e8;
|
||||
--log-debug: #3c89e8;
|
||||
--log-info: #008771;
|
||||
--log-notice: #008771;
|
||||
--log-warning: #f37b24;
|
||||
--log-error: #e04141;
|
||||
--log-unknown: #595959;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.log-stamp {
|
||||
color: var(--log-stamp);
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
color: var(--log-debug);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
color: var(--log-info);
|
||||
}
|
||||
|
||||
.level-notice {
|
||||
color: var(--log-notice);
|
||||
}
|
||||
|
||||
.level-warning {
|
||||
color: var(--log-warning);
|
||||
}
|
||||
|
||||
.level-error {
|
||||
color: var(--log-error);
|
||||
}
|
||||
|
||||
.level-unknown {
|
||||
color: var(--log-unknown);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
white-space: normal;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-line+.log-line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.log-level-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.log-body {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-body-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
:global(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;
|
||||
--log-notice: #4ed3a6;
|
||||
--log-warning: #ffb872;
|
||||
--log-error: #ff7575;
|
||||
--log-unknown: #b5b5b5;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global([data-theme="ultra-dark"]) .log-container {
|
||||
--log-stamp: #7fb6f1;
|
||||
--log-debug: #7fb6f1;
|
||||
--log-info: #5fd9b0;
|
||||
--log-notice: #5fd9b0;
|
||||
--log-warning: #ffcc88;
|
||||
--log-error: #ff8a8a;
|
||||
--log-unknown: #c4c4c4;
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Mobile: pull the modal flush with the screen edges. */
|
||||
:global(.logmodal-mobile) {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
:global(.logmodal-mobile .ant-modal-content) {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
:global(.logmodal-mobile .ant-modal-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Modal, Tag } from 'antd';
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import './PanelUpdateModal.css';
|
||||
|
||||
export interface PanelUpdateInfo {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
interface BusyEvent {
|
||||
busy: boolean;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
interface PanelUpdateModalProps {
|
||||
open: boolean;
|
||||
info: PanelUpdateInfo;
|
||||
onClose: () => void;
|
||||
onBusy: (e: BusyEvent) => void;
|
||||
}
|
||||
|
||||
export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
async function pollUntilBack(): Promise<boolean> {
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||
if (r?.data?.success) return true;
|
||||
} catch {
|
||||
/* still restarting */
|
||||
}
|
||||
await PromiseUtil.sleep(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function updatePanel() {
|
||||
modal.confirm({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', info.latestVersion || ''),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const baseTip = t('pages.index.dontRefresh');
|
||||
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
|
||||
onClose();
|
||||
onBusy({ busy: true, tip });
|
||||
const result = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||
if (!result?.success) {
|
||||
onBusy({ busy: false });
|
||||
return;
|
||||
}
|
||||
const back = await pollUntilBack();
|
||||
if (back) await PromiseUtil.sleep(800);
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.index.updatePanel')}
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{info.updateAvailable && (
|
||||
<Alert
|
||||
type="warning"
|
||||
className="mb-12"
|
||||
title={t('pages.index.panelUpdateDesc')}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="version-list">
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.currentPanelVersion')}</span>
|
||||
<Tag color="green">v{info.currentVersion || '?'}</Tag>
|
||||
</div>
|
||||
{info.updateAvailable ? (
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.latestPanelVersion')}</span>
|
||||
<Tag color="purple">{info.latestVersion || '-'}</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.panelUpToDate')}</span>
|
||||
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!info.updateAvailable}
|
||||
onClick={updatePanel}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
{t('pages.index.updatePanel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import axios from 'axios';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function updatePanel() {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const baseTip = t('pages.index.dontRefresh');
|
||||
const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
|
||||
close();
|
||||
emit('busy', { busy: true, tip });
|
||||
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||
if (!msg?.success) {
|
||||
emit('busy', { busy: false });
|
||||
return;
|
||||
}
|
||||
// Wait for the running process to exit, then poll the new panel
|
||||
// until it answers (up to ~90s). Reload as soon as it's back.
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
let back = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||
if (r?.data?.success) { back = true; break; }
|
||||
} catch (_) { /* still restarting */ }
|
||||
await PromiseUtil.sleep(2000);
|
||||
}
|
||||
if (back) {
|
||||
message.success(t('pages.index.panelUpdateStartedPopover'));
|
||||
await PromiseUtil.sleep(800);
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
|
||||
show-icon />
|
||||
|
||||
<a-list bordered class="version-list">
|
||||
<a-list-item class="version-list-item">
|
||||
<span>{{ t('pages.index.currentPanelVersion') }}</span>
|
||||
<a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
|
||||
</a-list-item>
|
||||
<a-list-item v-if="info.updateAvailable" class="version-list-item">
|
||||
<span>{{ t('pages.index.latestPanelVersion') }}</span>
|
||||
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
|
||||
</a-list-item>
|
||||
<a-list-item v-else class="version-list-item">
|
||||
<span>{{ t('pages.index.panelUpToDate') }}</span>
|
||||
<a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
|
||||
<div class="actions-row">
|
||||
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
|
||||
<template #icon>
|
||||
<CloudDownloadOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.updatePanel') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
.status-card .text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-card .ant-progress-text,
|
||||
.status-card .ant-progress-indicator {
|
||||
font-size: 12px !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Col, Progress, Row, Tooltip } from 'antd';
|
||||
import { AreaChartOutlined } from '@ant-design/icons';
|
||||
|
||||
import { CPUFormatter, SizeFormatter } from '@/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import type { Status } from '@/models/status';
|
||||
import './StatusCard.css';
|
||||
|
||||
interface StatusCardProps {
|
||||
status: Status;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export default function StatusCard({ status, isMobile }: StatusCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra } = useTheme();
|
||||
const gaugeSize = isMobile ? 60 : 90;
|
||||
const strokeWidth = isMobile ? 7 : 5;
|
||||
const railColor = isDark
|
||||
? isUltra ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.16)'
|
||||
: 'rgba(0, 0, 0, 0.08)';
|
||||
|
||||
return (
|
||||
<Card hoverable className="status-card">
|
||||
<Row gutter={[0, isMobile ? 16 : 0]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Row>
|
||||
<Col span={12} className="text-center">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
status="normal"
|
||||
strokeColor={status.cpu.color}
|
||||
railColor={railColor}
|
||||
strokeWidth={strokeWidth}
|
||||
percent={status.cpu.percent}
|
||||
size={gaugeSize}
|
||||
/>
|
||||
<div>
|
||||
<b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
<div>
|
||||
<b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
|
||||
</div>
|
||||
<div>
|
||||
<b>{t('pages.index.frequency')}:</b>{' '}
|
||||
{CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AreaChartOutlined />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={12} className="text-center">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
status="normal"
|
||||
strokeColor={status.mem.color}
|
||||
railColor={railColor}
|
||||
strokeWidth={strokeWidth}
|
||||
percent={status.mem.percent}
|
||||
size={gaugeSize}
|
||||
/>
|
||||
<div>
|
||||
<b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
|
||||
{SizeFormatter.sizeFormat(status.mem.total)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Row>
|
||||
<Col span={12} className="text-center">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
status="normal"
|
||||
strokeColor={status.swap.color}
|
||||
railColor={railColor}
|
||||
strokeWidth={strokeWidth}
|
||||
percent={status.swap.percent}
|
||||
size={gaugeSize}
|
||||
/>
|
||||
<div>
|
||||
<b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
|
||||
{SizeFormatter.sizeFormat(status.swap.total)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={12} className="text-center">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
status="normal"
|
||||
strokeColor={status.disk.color}
|
||||
railColor={railColor}
|
||||
strokeWidth={strokeWidth}
|
||||
percent={status.disk.percent}
|
||||
size={gaugeSize}
|
||||
/>
|
||||
<div>
|
||||
<b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
|
||||
{SizeFormatter.sizeFormat(status.disk.total)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AreaChartOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { CPUFormatter, SizeFormatter } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
// AD-Vue's default 120px dashboard renders the percent text at ~36px
|
||||
// which dwarfs the rest of the card. 70 (60 on mobile) plus the
|
||||
// :deep(.ant-progress-text) override below keep the gauges compact.
|
||||
const gaugeSize = computed(() => (props.isMobile ? 60 : 70));
|
||||
|
||||
// AD-Vue's default unfinished trail (rgba(0,0,0,0.06) /
|
||||
// rgba(255,255,255,0.08)) is invisible against the light card; a
|
||||
// neutral mid-gray reads on both themes.
|
||||
const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<a-row :gutter="[0, isMobile ? 16 : 0]">
|
||||
<!-- CPU + Memory -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-row>
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
|
||||
:percent="status.cpu.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
|
||||
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
|
||||
</div>
|
||||
</template>
|
||||
<AreaChartOutlined />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
|
||||
:percent="status.mem.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
|
||||
{{ SizeFormatter.sizeFormat(status.mem.total) }}
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
|
||||
<!-- Swap + Disk -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-row>
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
|
||||
:percent="status.swap.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
|
||||
{{ SizeFormatter.sizeFormat(status.swap.total) }}
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
|
||||
:percent="status.disk.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
|
||||
{{ SizeFormatter.sizeFormat(status.disk.total) }}
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Pin the percent number to a label-sized 14px — AD-Vue scales it
|
||||
* from the SVG's intrinsic size, so :width alone leaves it too big. */
|
||||
:deep(.ant-progress-text) {
|
||||
font-size: 14px !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
.bucket-select {
|
||||
width: 80px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.history-tabs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cpu-chart-wrap {
|
||||
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);
|
||||
}
|
||||
|
||||
.cpu-chart-meta {
|
||||
margin-bottom: 12px;
|
||||
font-size: 11.5px;
|
||||
opacity: 0.65;
|
||||
letter-spacing: 0.3px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Select, Tabs } from 'antd';
|
||||
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import type { Status } from '@/models/status';
|
||||
import './SystemHistoryModal.css';
|
||||
|
||||
interface SystemHistoryModalProps {
|
||||
open: boolean;
|
||||
status: Status;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MetricDef {
|
||||
key: string;
|
||||
tab: string;
|
||||
valueMax: number | null;
|
||||
unit: string;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{ key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
|
||||
{ key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
|
||||
{ key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
|
||||
{ key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
|
||||
{ key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
|
||||
{ key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
|
||||
{ key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
|
||||
{ key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
|
||||
];
|
||||
|
||||
function unitFormatter(unit: string, activeKey: string): (v: number) => string {
|
||||
if (unit === 'B/s') {
|
||||
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
|
||||
}
|
||||
if (unit === '%') {
|
||||
return (v) => `${Number(v).toFixed(1)}%`;
|
||||
}
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
if (activeKey === 'online') return String(Math.round(n));
|
||||
return n.toFixed(2);
|
||||
};
|
||||
}
|
||||
|
||||
export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [activeKey, setActiveKey] = useState('cpu');
|
||||
const [bucket, setBucket] = useState(2);
|
||||
const [points, setPoints] = useState<number[]>([]);
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
|
||||
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
|
||||
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
|
||||
const yFormatter = useMemo(
|
||||
() => unitFormatter(activeMetric?.unit ?? '', activeKey),
|
||||
[activeMetric, activeKey],
|
||||
);
|
||||
|
||||
const fetchBucket = useCallback(async () => {
|
||||
if (!activeMetric) return;
|
||||
try {
|
||||
const url = `/panel/api/server/history/${activeMetric.key}/${bucket}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals: number[] = [];
|
||||
const labs: string[] = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||
vals.push(Number(p.v) || 0);
|
||||
}
|
||||
setLabels(labs);
|
||||
setPoints(vals);
|
||||
} else {
|
||||
setLabels([]);
|
||||
setPoints([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch history bucket', e);
|
||||
setLabels([]);
|
||||
setPoints([]);
|
||||
}
|
||||
}, [activeMetric, bucket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveKey('cpu');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchBucket();
|
||||
}, [open, activeKey, bucket, fetchBucket]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '95vw' : 900}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<>
|
||||
{t('pages.index.systemHistoryTitle')}
|
||||
<Select
|
||||
value={bucket}
|
||||
size="small"
|
||||
className="bucket-select"
|
||||
onChange={setBucket}
|
||||
options={[
|
||||
{ value: 2, label: '2m' },
|
||||
{ value: 30, label: '30m' },
|
||||
{ value: 60, label: '1h' },
|
||||
{ value: 120, label: '2h' },
|
||||
{ value: 180, label: '3h' },
|
||||
{ value: 300, label: '5h' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
size="small"
|
||||
className="history-tabs"
|
||||
items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
|
||||
/>
|
||||
|
||||
<div className="cpu-chart-wrap">
|
||||
<div className="cpu-chart-meta">
|
||||
Timeframe: {bucket} sec per point (total {points.length} points)
|
||||
</div>
|
||||
<Sparkline
|
||||
data={points}
|
||||
labels={labels}
|
||||
vbWidth={840}
|
||||
height={220}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2.2}
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={5}
|
||||
maxPoints={points.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={3.2}
|
||||
showTooltip
|
||||
valueMin={0}
|
||||
valueMax={activeMetric?.valueMax ?? null}
|
||||
yFormatter={yFormatter}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
status: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
// One tab per system metric. The order here drives the tab order in
|
||||
// the UI; everything else (axis label, tooltip unit, fetch URL) is
|
||||
// looked up from the active key. Adding another metric is one row.
|
||||
const metrics = [
|
||||
{ key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
|
||||
{ key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
|
||||
{ key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
|
||||
{ key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
|
||||
{ key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
|
||||
{ key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
|
||||
{ key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
|
||||
{ key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
|
||||
];
|
||||
|
||||
const activeKey = ref('cpu');
|
||||
const bucket = ref(2);
|
||||
const points = ref([]);
|
||||
const labels = ref([]);
|
||||
|
||||
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
||||
|
||||
// CPU keeps using the status-card color so the modal visually echoes
|
||||
// the dot in StatusCard. Non-CPU tabs each get their own constant color.
|
||||
const strokeColor = computed(() => {
|
||||
const m = activeMetric.value;
|
||||
if (m?.stroke) return m.stroke;
|
||||
return props.status?.cpu?.color || '#008771';
|
||||
});
|
||||
|
||||
function unitFormatter(unit) {
|
||||
if (unit === 'B/s') {
|
||||
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
|
||||
}
|
||||
if (unit === '%') {
|
||||
return (v) => `${Number(v).toFixed(1)}%`;
|
||||
}
|
||||
// Plain numbers: load averages get two decimals, online client count
|
||||
// is integer. Heuristic on the unit-less metric key is good enough.
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
if (activeKey.value === 'online') return String(Math.round(n));
|
||||
return n.toFixed(2);
|
||||
};
|
||||
}
|
||||
|
||||
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
||||
|
||||
async function fetchBucket() {
|
||||
const m = activeMetric.value;
|
||||
if (!m) return;
|
||||
try {
|
||||
const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals = [];
|
||||
const labs = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||
vals.push(Number(p.v) || 0);
|
||||
}
|
||||
labels.value = labs;
|
||||
points.value = vals;
|
||||
} else {
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch history bucket', e);
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) {
|
||||
activeKey.value = 'cpu';
|
||||
fetchBucket();
|
||||
}
|
||||
});
|
||||
watch([activeKey, bucket], () => {
|
||||
if (props.open) fetchBucket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.systemHistoryTitle') }}
|
||||
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
||||
<a-select-option :value="2">2m</a-select-option>
|
||||
<a-select-option :value="30">30m</a-select-option>
|
||||
<a-select-option :value="60">1h</a-select-option>
|
||||
<a-select-option :value="120">2h</a-select-option>
|
||||
<a-select-option :value="180">3h</a-select-option>
|
||||
<a-select-option :value="300">5h</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
|
||||
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="cpu-chart-wrap">
|
||||
<div class="cpu-chart-meta">
|
||||
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
||||
</div>
|
||||
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
|
||||
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
|
||||
:y-formatter="yFormatter" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bucket-select {
|
||||
width: 80px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.history-tabs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cpu-chart-wrap {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.cpu-chart-meta {
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { Status } from '@/models/status';
|
||||
import CustomGeoSection from './CustomGeoSection';
|
||||
import './VersionModal.css';
|
||||
|
||||
interface BusyEvent {
|
||||
busy: boolean;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
interface VersionModalProps {
|
||||
open: boolean;
|
||||
status: Status;
|
||||
onClose: () => void;
|
||||
onBusy: (e: BusyEvent) => void;
|
||||
}
|
||||
|
||||
const GEOFILES = [
|
||||
'geosite.dat',
|
||||
'geoip.dat',
|
||||
'geosite_IR.dat',
|
||||
'geoip_IR.dat',
|
||||
'geosite_RU.dat',
|
||||
'geoip_RU.dat',
|
||||
];
|
||||
|
||||
export default function VersionModal({ open, status, onClose, onBusy }: VersionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [activeKey, setActiveKey] = useState<string | string[]>('1');
|
||||
const [versions, setVersions] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
||||
if (msg?.success) setVersions(msg.obj || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchVersions();
|
||||
}, [open, fetchVersions]);
|
||||
|
||||
function switchXrayVersion(version: string) {
|
||||
modal.confirm({
|
||||
title: t('pages.index.xraySwitchVersionDialog'),
|
||||
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
onClose();
|
||||
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
|
||||
try {
|
||||
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
|
||||
} finally {
|
||||
onBusy({ busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateGeofile(fileName: string) {
|
||||
const isSingle = !!fileName;
|
||||
modal.confirm({
|
||||
title: t('pages.index.geofileUpdateDialog'),
|
||||
content: isSingle
|
||||
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
|
||||
: t('pages.index.geofilesUpdateDialogDesc'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
onClose();
|
||||
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
|
||||
const url = isSingle
|
||||
? `/panel/api/server/updateGeofile/${fileName}`
|
||||
: '/panel/api/server/updateGeofile';
|
||||
try {
|
||||
await HttpUtil.post(url);
|
||||
} finally {
|
||||
onBusy({ busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const activeKeyStr = Array.isArray(activeKey) ? activeKey[0] : activeKey;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.index.xrayUpdates')}
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{modalContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Collapse
|
||||
accordion
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Xray',
|
||||
children: (
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
className="mb-12"
|
||||
title={t('pages.index.xraySwitchClickDesk')}
|
||||
showIcon
|
||||
/>
|
||||
<div className="version-list">
|
||||
{versions.map((version, index) => (
|
||||
<div key={version} className="version-list-item">
|
||||
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
|
||||
<Radio
|
||||
checked={version === `v${status?.xray?.version}`}
|
||||
onClick={() => switchXrayVersion(version)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Geofiles',
|
||||
children: (
|
||||
<>
|
||||
<div className="version-list">
|
||||
{GEOFILES.map((file, index) => (
|
||||
<div key={file} className="version-list-item">
|
||||
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
|
||||
<Tooltip title={t('update')}>
|
||||
<ReloadOutlined
|
||||
className="reload-icon"
|
||||
onClick={() => updateGeofile(file)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="actions-row">
|
||||
<Button onClick={() => updateGeofile('')}>
|
||||
{t('pages.index.geofilesUpdateAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('pages.index.customGeoTitle'),
|
||||
children: <CustomGeoSection active={activeKeyStr === '3'} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import CustomGeoSection from './CustomGeoSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
status: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
const activeKey = ref('1');
|
||||
const versions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Geofiles list is hardcoded in the legacy panel — same set of files
|
||||
// served from /panel/api/server/updateGeofile/{name}.
|
||||
const GEOFILES = ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat'];
|
||||
|
||||
async function fetchVersions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
||||
if (msg?.success) versions.value = msg.obj || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function switchXrayVersion(version) {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.xraySwitchVersionDialog'),
|
||||
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
||||
try {
|
||||
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
|
||||
} finally {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateGeofile(fileName) {
|
||||
const isSingle = !!fileName;
|
||||
Modal.confirm({
|
||||
title: t('pages.index.geofileUpdateDialog'),
|
||||
content: isSingle
|
||||
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
|
||||
: t('pages.index.geofilesUpdateDialogDesc'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
||||
const url = isSingle
|
||||
? `/panel/api/server/updateGeofile/${fileName}`
|
||||
: '/panel/api/server/updateGeofile';
|
||||
try {
|
||||
await HttpUtil.post(url);
|
||||
} finally {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => { if (next) fetchVersions(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.xrayUpdates')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-spin :spinning="loading">
|
||||
<a-collapse v-model:active-key="activeKey" accordion>
|
||||
<a-collapse-panel key="1" header="Xray">
|
||||
<a-alert type="warning" class="mb-12" :message="t('pages.index.xraySwitchClickDesk')" show-icon />
|
||||
<a-list bordered class="version-list">
|
||||
<a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
|
||||
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
|
||||
<a-radio :checked="version === `v${status?.xray?.version}`" @click="switchXrayVersion(version)" />
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" header="Geofiles">
|
||||
<a-list bordered class="version-list">
|
||||
<a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
|
||||
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
|
||||
<a-tooltip :title="t('update')">
|
||||
<ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
|
||||
</a-tooltip>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
<div class="actions-row">
|
||||
<a-button @click="updateGeofile('')">{{ t('pages.index.geofilesUpdateAll') }}</a-button>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.index.customGeoTitle')">
|
||||
<CustomGeoSection :active="activeKey === '3'" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .filter-item {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
--log-blocked: #e04141;
|
||||
--log-proxy: #3c89e8;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-event-tag {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-addr {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.log-meta-pair {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-meta-key {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .log-container {
|
||||
--log-blocked: #ff8a8a;
|
||||
--log-proxy: #7fb6f1;
|
||||
--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;
|
||||
}
|
||||
|
||||
.xraylog-modal-mobile .ant-modal-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.xraylog-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xraylog-table td,
|
||||
.xraylog-table th {
|
||||
padding: 2px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-1 {
|
||||
color: var(--log-blocked);
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-2 {
|
||||
color: var(--log-proxy);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import './XrayLogModal.css';
|
||||
|
||||
interface XrayLogModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface XrayLogEntry {
|
||||
DateTime?: string | number;
|
||||
FromAddress?: string;
|
||||
ToAddress?: string;
|
||||
Inbound?: string;
|
||||
Outbound?: string;
|
||||
Email?: string;
|
||||
Event?: number;
|
||||
}
|
||||
|
||||
const EVENT_LABELS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||
const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
|
||||
|
||||
function eventLabel(ev?: number): string {
|
||||
return EVENT_LABELS[ev ?? -1] ?? String(ev ?? '');
|
||||
}
|
||||
|
||||
function eventColor(ev?: number): string {
|
||||
return EVENT_COLORS[ev ?? -1] ?? 'default';
|
||||
}
|
||||
|
||||
function shortTime(value?: string | number): string {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [rows, setRows] = useState('20');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showDirect, setShowDirect] = useState(true);
|
||||
const [showBlocked, setShowBlocked] = useState(true);
|
||||
const [showProxy, setShowProxy] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
|
||||
const openRef = useRef(open);
|
||||
|
||||
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows}`, {
|
||||
filter,
|
||||
showDirect,
|
||||
showBlocked,
|
||||
showProxy,
|
||||
});
|
||||
if (msg?.success) setLogs(msg.obj || []);
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rows, filter, showDirect, showBlocked, showProxy]);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
if (open) refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openRef.current) refresh();
|
||||
}, [rows, showDirect, showBlocked, showProxy, refresh]);
|
||||
|
||||
function fullDate(value?: string | number): string {
|
||||
return IntlUtil.formatDate(value, datepicker);
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!Array.isArray(logs) || logs.length === 0) {
|
||||
FileManager.downloadTextFile('', 'x-ui.log');
|
||||
return;
|
||||
}
|
||||
const lines = logs.map((l) => {
|
||||
try {
|
||||
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||
const eventText = eventLabel(l.Event);
|
||||
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||
} catch {
|
||||
return JSON.stringify(l);
|
||||
}
|
||||
}).join('\n');
|
||||
FileManager.downloadTextFile(lines, 'x-ui.log');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : '80vw'}
|
||||
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<>
|
||||
{t('pages.index.logs')}
|
||||
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form layout="inline" className="log-toolbar">
|
||||
<Form.Item>
|
||||
<Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
|
||||
<Select.Option value="10">10</Select.Option>
|
||||
<Select.Option value="20">20</Select.Option>
|
||||
<Select.Option value="50">50</Select.Option>
|
||||
<Select.Option value="100">100</Select.Option>
|
||||
<Select.Option value="500">500</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('filter')} className="filter-item">
|
||||
<Input
|
||||
value={filter}
|
||||
size="small"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter') refresh();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={showDirect} onChange={(e) => setShowDirect(e.target.checked)}>
|
||||
Direct
|
||||
</Checkbox>
|
||||
<Checkbox checked={showBlocked} onChange={(e) => setShowBlocked(e.target.checked)}>
|
||||
Blocked
|
||||
</Checkbox>
|
||||
<Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
|
||||
Proxy
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||
{orderedLogs.length === 0 ? (
|
||||
<div className="log-empty">No Record...</div>
|
||||
) : isMobile ? (
|
||||
orderedLogs.map((log, idx) => (
|
||||
<div key={idx} className="log-card">
|
||||
<div className="log-card-head">
|
||||
<span className="log-time" title={fullDate(log.DateTime)}>
|
||||
{shortTime(log.DateTime)}
|
||||
</span>
|
||||
<Tag color={eventColor(log.Event)} className="log-event-tag">
|
||||
{eventLabel(log.Event)}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="log-route">
|
||||
<span className="log-addr">{log.FromAddress}</span>
|
||||
<span className="log-arrow">→</span>
|
||||
<span className="log-addr">{log.ToAddress}</span>
|
||||
</div>
|
||||
<div className="log-meta">
|
||||
{log.Inbound && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">in</span>
|
||||
<span className="log-meta-val">{log.Inbound}</span>
|
||||
</span>
|
||||
)}
|
||||
{log.Outbound && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">out</span>
|
||||
<span className="log-meta-val">{log.Outbound}</span>
|
||||
</span>
|
||||
)}
|
||||
{log.Email && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">email</span>
|
||||
<span className="log-meta-val">{log.Email}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<table className="xraylog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Inbound</th>
|
||||
<th>Outbound</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orderedLogs.map((log, idx) => (
|
||||
<tr key={idx} className={`log-row-${log.Event}`}>
|
||||
<td>
|
||||
<b>{fullDate(log.DateTime)}</b>
|
||||
</td>
|
||||
<td>{log.FromAddress}</td>
|
||||
<td>{log.ToAddress}</td>
|
||||
<td>{log.Inbound}</td>
|
||||
<td>{log.Outbound}</td>
|
||||
<td>{log.Email}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const rows = ref('20');
|
||||
const filter = ref('');
|
||||
const showDirect = ref(true);
|
||||
const showBlocked = ref(true);
|
||||
const showProxy = ref(true);
|
||||
const loading = ref(false);
|
||||
const logs = ref([]);
|
||||
|
||||
// Newest first.
|
||||
const orderedLogs = computed(() => [...logs.value].reverse());
|
||||
|
||||
const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||
const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
|
||||
|
||||
function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
|
||||
function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
|
||||
|
||||
function fullDate(value) {
|
||||
return IntlUtil.formatDate(value, datepicker.value);
|
||||
}
|
||||
function shortTime(value) {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
|
||||
filter: filter.value,
|
||||
showDirect: showDirect.value,
|
||||
showBlocked: showBlocked.value,
|
||||
showProxy: showProxy.value,
|
||||
});
|
||||
if (msg?.success) logs.value = msg.obj || [];
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!Array.isArray(logs.value) || logs.value.length === 0) {
|
||||
FileManager.downloadTextFile('', 'x-ui.log');
|
||||
return;
|
||||
}
|
||||
const lines = logs.value.map((l) => {
|
||||
try {
|
||||
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||
const eventText = eventLabel(l.Event);
|
||||
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||
} catch (_e) {
|
||||
return JSON.stringify(l);
|
||||
}
|
||||
}).join('\n');
|
||||
FileManager.downloadTextFile(lines, 'x-ui.log');
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => { if (next) refresh(); });
|
||||
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
|
||||
|
||||
const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
|
||||
:class="{ 'xraylog-modal-mobile': isMobile }" @cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.logs') }}
|
||||
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
||||
</template>
|
||||
|
||||
<a-form layout="inline" class="log-toolbar">
|
||||
<a-form-item>
|
||||
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
||||
<a-select-option value="10">10</a-select-option>
|
||||
<a-select-option value="20">20</a-select-option>
|
||||
<a-select-option value="50">50</a-select-option>
|
||||
<a-select-option value="100">100</a-select-option>
|
||||
<a-select-option value="500">500</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('filter')" class="filter-item">
|
||||
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
|
||||
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
|
||||
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item class="download-item">
|
||||
<a-button type="primary" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
||||
<div v-if="orderedLogs.length === 0" class="log-empty">No Record...</div>
|
||||
|
||||
<template v-else-if="isMobile">
|
||||
<div v-for="(log, idx) in orderedLogs" :key="idx" class="log-card">
|
||||
<div class="log-card-head">
|
||||
<span class="log-time" :title="fullDate(log.DateTime)">{{ shortTime(log.DateTime) }}</span>
|
||||
<a-tag :color="eventColor(log.Event)" class="log-event-tag">{{ eventLabel(log.Event) }}</a-tag>
|
||||
</div>
|
||||
<div class="log-route">
|
||||
<span class="log-addr">{{ log.FromAddress }}</span>
|
||||
<span class="log-arrow">→</span>
|
||||
<span class="log-addr">{{ log.ToAddress }}</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span v-if="log.Inbound" class="log-meta-pair">
|
||||
<span class="log-meta-key">in</span>
|
||||
<span class="log-meta-val">{{ log.Inbound }}</span>
|
||||
</span>
|
||||
<span v-if="log.Outbound" class="log-meta-pair">
|
||||
<span class="log-meta-key">out</span>
|
||||
<span class="log-meta-val">{{ log.Outbound }}</span>
|
||||
</span>
|
||||
<span v-if="log.Email" class="log-meta-pair">
|
||||
<span class="log-meta-key">email</span>
|
||||
<span class="log-meta-val">{{ log.Email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table v-else class="xraylog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Inbound</th>
|
||||
<th>Outbound</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(log, idx) in orderedLogs" :key="idx" :class="`log-row-${log.Event}`">
|
||||
<td><b>{{ fullDate(log.DateTime) }}</b></td>
|
||||
<td>{{ log.FromAddress }}</td>
|
||||
<td>{{ log.ToAddress }}</td>
|
||||
<td>{{ log.Inbound }}</td>
|
||||
<td>{{ log.Outbound }}</td>
|
||||
<td>{{ log.Email }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .filter-item {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
||||
below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
|
||||
--log-blocked: #e04141;
|
||||
--log-proxy: #3c89e8;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-event-tag {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-addr {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.log-meta-pair {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-meta-key {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
:global(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);
|
||||
}
|
||||
|
||||
:global([data-theme="ultra-dark"]) .log-container {
|
||||
--log-blocked: #ff8a8a;
|
||||
--log-proxy: #7fb6f1;
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Mobile: pull the modal flush with the screen edges. */
|
||||
:global(.xraylog-modal-mobile) {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
:global(.xraylog-modal-mobile .ant-modal-content) {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
:global(.xraylog-modal-mobile .ant-modal-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.xraylog-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xraylog-table td,
|
||||
.xraylog-table th {
|
||||
padding: 2px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-1 {
|
||||
color: var(--log-blocked);
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-2 {
|
||||
color: var(--log-proxy);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
.metrics-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.obs-pane {
|
||||
padding: 4px 16px 0;
|
||||
}
|
||||
|
||||
.obs-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.obs-select {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.obs-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.obs-stamp {
|
||||
opacity: 0.7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.obs-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.18);
|
||||
}
|
||||
|
||||
.obs-dot.is-alive {
|
||||
background: #52c41a;
|
||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.22);
|
||||
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);
|
||||
}
|
||||
|
||||
@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); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.obs-dot.is-alive { animation: none; }
|
||||
}
|
||||
|
||||
.listen-tag {
|
||||
opacity: 0.7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Tabs, Tag } from 'antd';
|
||||
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import './XrayMetricsModal.css';
|
||||
|
||||
const OBS_KEY = 'xrObs';
|
||||
|
||||
interface XrayMetricsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MetricDef {
|
||||
key: string;
|
||||
tab: string;
|
||||
unit: 'B' | 'ns' | 'ms' | '';
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
interface XrayState {
|
||||
enabled: boolean;
|
||||
listen: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ObservatoryTag {
|
||||
tag: string;
|
||||
alive: boolean;
|
||||
delay: number;
|
||||
lastSeenTime: number;
|
||||
lastTryTime: number;
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{ key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' },
|
||||
{ key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' },
|
||||
{ key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' },
|
||||
{ key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' },
|
||||
{ key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' },
|
||||
{ key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' },
|
||||
];
|
||||
|
||||
function unitFormatter(unit: string): (v: number) => string {
|
||||
if (unit === 'B') return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
|
||||
if (unit === 'ns') {
|
||||
return (v) => {
|
||||
const n = Math.max(0, Number(v) || 0);
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
|
||||
return `${n.toFixed(0)} ns`;
|
||||
};
|
||||
}
|
||||
if (unit === 'ms') return (v) => `${Math.round(Number(v) || 0)} ms`;
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
return Math.round(n).toLocaleString();
|
||||
};
|
||||
}
|
||||
|
||||
function fmtTimestamp(unixSec: number): string {
|
||||
if (!unixSec) return '—';
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [activeKey, setActiveKey] = useState('xrAlloc');
|
||||
const [bucket, setBucket] = useState(2);
|
||||
const [points, setPoints] = useState<number[]>([]);
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
|
||||
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
|
||||
const [obsActiveTag, setObsActiveTag] = useState('');
|
||||
const obsTimerRef = useRef<number | null>(null);
|
||||
const openRef = useRef(open);
|
||||
|
||||
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
|
||||
const isObservatory = activeKey === OBS_KEY;
|
||||
const strokeColor = activeMetric?.stroke || '#008771';
|
||||
const yFormatter = useMemo(() => unitFormatter(activeMetric?.unit ?? ''), [activeMetric]);
|
||||
|
||||
const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
|
||||
|
||||
const applyHistory = useCallback((msg: { success?: boolean; obj?: { t: number; v: number }[] }, currentBucket: number) => {
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals: number[] = [];
|
||||
const labs: string[] = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labs.push(currentBucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||
vals.push(Number(p.v) || 0);
|
||||
}
|
||||
setLabels(labs);
|
||||
setPoints(vals);
|
||||
} else {
|
||||
setLabels([]);
|
||||
setPoints([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
|
||||
if (msg?.success && msg.obj) setState(msg.obj);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch xray metrics state', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchObservatory = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
setObsTags(msg.obj);
|
||||
setObsActiveTag((prev) => {
|
||||
if (msg.obj.find((tg: ObservatoryTag) => tg.tag === prev)) return prev;
|
||||
return msg.obj[0]?.tag || '';
|
||||
});
|
||||
} else {
|
||||
setObsTags([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch observatory snapshot', e);
|
||||
setObsTags([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchMetricBucket = useCallback(async () => {
|
||||
if (!activeMetric) return;
|
||||
try {
|
||||
const url = `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
applyHistory(msg, bucket);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch xray metrics bucket', e);
|
||||
setLabels([]);
|
||||
setPoints([]);
|
||||
}
|
||||
}, [activeMetric, bucket, applyHistory]);
|
||||
|
||||
const fetchObsBucket = useCallback(async () => {
|
||||
if (!obsActiveTag) {
|
||||
setLabels([]);
|
||||
setPoints([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
applyHistory(msg, bucket);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch observatory bucket', e);
|
||||
setLabels([]);
|
||||
setPoints([]);
|
||||
}
|
||||
}, [obsActiveTag, bucket, applyHistory]);
|
||||
|
||||
const stopObsPolling = useCallback(() => {
|
||||
if (obsTimerRef.current != null) {
|
||||
window.clearInterval(obsTimerRef.current);
|
||||
obsTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
if (open) {
|
||||
setActiveKey('xrAlloc');
|
||||
fetchState();
|
||||
} else {
|
||||
stopObsPolling();
|
||||
}
|
||||
}, [open, fetchState, stopObsPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (isObservatory) {
|
||||
fetchObservatory();
|
||||
fetchObsBucket();
|
||||
stopObsPolling();
|
||||
obsTimerRef.current = window.setInterval(async () => {
|
||||
if (!openRef.current || !isObservatory) return;
|
||||
await fetchObservatory();
|
||||
fetchObsBucket();
|
||||
}, 2000);
|
||||
} else {
|
||||
stopObsPolling();
|
||||
fetchMetricBucket();
|
||||
}
|
||||
return () => {
|
||||
stopObsPolling();
|
||||
};
|
||||
}, [open, activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (isObservatory) {
|
||||
fetchObsBucket();
|
||||
} else {
|
||||
fetchMetricBucket();
|
||||
}
|
||||
}, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isObservatory) fetchObsBucket();
|
||||
}, [open, obsActiveTag, isObservatory, fetchObsBucket]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '95vw' : 900}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<>
|
||||
{t('pages.index.xrayMetricsTitle')}
|
||||
<Select
|
||||
value={bucket}
|
||||
size="small"
|
||||
className="bucket-select"
|
||||
onChange={setBucket}
|
||||
options={[
|
||||
{ value: 2, label: '2m' },
|
||||
{ value: 30, label: '30m' },
|
||||
{ value: 60, label: '1h' },
|
||||
{ value: 120, label: '2h' },
|
||||
{ value: 180, label: '3h' },
|
||||
{ value: 300, label: '5h' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!state.enabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="metrics-alert"
|
||||
title={t('pages.index.xrayMetricsDisabled')}
|
||||
description={state.reason || t('pages.index.xrayMetricsHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
size="small"
|
||||
className="history-tabs"
|
||||
items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
|
||||
/>
|
||||
|
||||
{isObservatory && (
|
||||
<div className="obs-pane">
|
||||
{state.enabled && obsTags.length === 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="metrics-alert"
|
||||
title={t('pages.index.xrayObservatoryEmpty')}
|
||||
description={t('pages.index.xrayObservatoryHint')}
|
||||
/>
|
||||
) : (
|
||||
<div className="obs-controls">
|
||||
<Select
|
||||
value={obsActiveTag}
|
||||
size="small"
|
||||
className="obs-select"
|
||||
placeholder={t('pages.index.xrayObservatoryTagPlaceholder')}
|
||||
onChange={setObsActiveTag}
|
||||
options={obsTags.map((tg) => ({
|
||||
value: tg.tag,
|
||||
label: (
|
||||
<>
|
||||
<span className={`obs-dot ${tg.alive ? 'is-alive' : 'is-dead'}`} />
|
||||
{tg.tag}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{activeObsTag && (
|
||||
<div className="obs-stats">
|
||||
<Tag color={activeObsTag.alive ? 'green' : 'red'}>
|
||||
{activeObsTag.alive
|
||||
? t('pages.index.xrayObservatoryAlive')
|
||||
: t('pages.index.xrayObservatoryDead')}
|
||||
</Tag>
|
||||
<Tag color="blue">{activeObsTag.delay} ms</Tag>
|
||||
<span className="obs-stamp">
|
||||
{t('pages.index.xrayObservatoryLastSeen')}: {fmtTimestamp(activeObsTag.lastSeenTime)}
|
||||
</span>
|
||||
<span className="obs-stamp">
|
||||
{t('pages.index.xrayObservatoryLastTry')}: {fmtTimestamp(activeObsTag.lastTryTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cpu-chart-wrap">
|
||||
<div className="cpu-chart-meta">
|
||||
Timeframe: {bucket} sec per point (total {points.length} points)
|
||||
{state.enabled && state.listen && (
|
||||
<span className="listen-tag"> · {state.listen}</span>
|
||||
)}
|
||||
</div>
|
||||
<Sparkline
|
||||
data={points}
|
||||
labels={labels}
|
||||
vbWidth={840}
|
||||
height={220}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2.2}
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={5}
|
||||
maxPoints={points.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={3.2}
|
||||
showTooltip
|
||||
valueMin={0}
|
||||
valueMax={null}
|
||||
yFormatter={yFormatter}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const OBS_KEY = 'xrObs';
|
||||
|
||||
const metrics = [
|
||||
{ key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' },
|
||||
{ key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' },
|
||||
{ key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' },
|
||||
{ key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' },
|
||||
{ key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' },
|
||||
{ key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' },
|
||||
];
|
||||
|
||||
const activeKey = ref('xrAlloc');
|
||||
const bucket = ref(2);
|
||||
const points = ref([]);
|
||||
const labels = ref([]);
|
||||
const state = ref({ enabled: false, listen: '', reason: '' });
|
||||
const obsTags = ref([]);
|
||||
const obsActiveTag = ref('');
|
||||
let obsTimer = null;
|
||||
|
||||
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
||||
const isObservatory = computed(() => activeKey.value === OBS_KEY);
|
||||
const strokeColor = computed(() => activeMetric.value?.stroke || '#008771');
|
||||
const activeObsTag = computed(() => obsTags.value.find((tg) => tg.tag === obsActiveTag.value) || null);
|
||||
|
||||
function unitFormatter(unit) {
|
||||
if (unit === 'B') {
|
||||
return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
|
||||
}
|
||||
if (unit === 'ns') {
|
||||
return (v) => {
|
||||
const n = Math.max(0, Number(v) || 0);
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
|
||||
return `${n.toFixed(0)} ns`;
|
||||
};
|
||||
}
|
||||
if (unit === 'ms') {
|
||||
return (v) => `${Math.round(Number(v) || 0)} ms`;
|
||||
}
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
return Math.round(n).toLocaleString();
|
||||
};
|
||||
}
|
||||
|
||||
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
||||
|
||||
function fmtTimestamp(unixSec) {
|
||||
if (!unixSec) return '—';
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
|
||||
if (msg?.success && msg.obj) state.value = msg.obj;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch xray metrics state', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchObservatory() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
obsTags.value = msg.obj;
|
||||
if (!obsTags.value.find((tg) => tg.tag === obsActiveTag.value)) {
|
||||
obsActiveTag.value = obsTags.value[0]?.tag || '';
|
||||
}
|
||||
} else {
|
||||
obsTags.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch observatory snapshot', e);
|
||||
obsTags.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetricBucket() {
|
||||
const m = activeMetric.value;
|
||||
if (!m) return;
|
||||
try {
|
||||
const url = `/panel/api/server/xrayMetricsHistory/${m.key}/${bucket.value}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
applyHistory(msg);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch xray metrics bucket', e);
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchObsBucket() {
|
||||
const tag = obsActiveTag.value;
|
||||
if (!tag) {
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(tag)}/${bucket.value}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
applyHistory(msg);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch observatory bucket', e);
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyHistory(msg) {
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals = [];
|
||||
const labs = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||
vals.push(Number(p.v) || 0);
|
||||
}
|
||||
labels.value = labs;
|
||||
points.value = vals;
|
||||
} else {
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
if (isObservatory.value) {
|
||||
fetchObsBucket();
|
||||
} else {
|
||||
fetchMetricBucket();
|
||||
}
|
||||
}
|
||||
|
||||
function startObsPolling() {
|
||||
stopObsPolling();
|
||||
obsTimer = window.setInterval(async () => {
|
||||
if (!props.open || !isObservatory.value) return;
|
||||
await fetchObservatory();
|
||||
fetchObsBucket();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopObsPolling() {
|
||||
if (obsTimer != null) {
|
||||
window.clearInterval(obsTimer);
|
||||
obsTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) {
|
||||
activeKey.value = 'xrAlloc';
|
||||
fetchState();
|
||||
fetchMetricBucket();
|
||||
} else {
|
||||
stopObsPolling();
|
||||
}
|
||||
});
|
||||
|
||||
watch(activeKey, async (key) => {
|
||||
if (!props.open) return;
|
||||
if (key === OBS_KEY) {
|
||||
await fetchObservatory();
|
||||
fetchObsBucket();
|
||||
startObsPolling();
|
||||
} else {
|
||||
stopObsPolling();
|
||||
fetchMetricBucket();
|
||||
}
|
||||
});
|
||||
|
||||
watch(bucket, () => {
|
||||
if (props.open) refreshActive();
|
||||
});
|
||||
|
||||
watch(obsActiveTag, () => {
|
||||
if (props.open && isObservatory.value) fetchObsBucket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.xrayMetricsTitle') }}
|
||||
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
||||
<a-select-option :value="2">2m</a-select-option>
|
||||
<a-select-option :value="30">30m</a-select-option>
|
||||
<a-select-option :value="60">1h</a-select-option>
|
||||
<a-select-option :value="120">2h</a-select-option>
|
||||
<a-select-option :value="180">3h</a-select-option>
|
||||
<a-select-option :value="300">5h</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="!state.enabled" type="warning" show-icon class="metrics-alert"
|
||||
:message="t('pages.index.xrayMetricsDisabled')"
|
||||
:description="state.reason || t('pages.index.xrayMetricsHint')" />
|
||||
|
||||
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
|
||||
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
|
||||
</a-tabs>
|
||||
|
||||
<div v-if="isObservatory" class="obs-pane">
|
||||
<a-alert v-if="state.enabled && obsTags.length === 0" type="info" show-icon class="metrics-alert"
|
||||
:message="t('pages.index.xrayObservatoryEmpty')"
|
||||
:description="t('pages.index.xrayObservatoryHint')" />
|
||||
|
||||
<div v-else class="obs-controls">
|
||||
<a-select v-model:value="obsActiveTag" size="small" class="obs-select"
|
||||
:placeholder="t('pages.index.xrayObservatoryTagPlaceholder')">
|
||||
<a-select-option v-for="tg in obsTags" :key="tg.tag" :value="tg.tag">
|
||||
<span class="obs-dot" :class="tg.alive ? 'is-alive' : 'is-dead'" />
|
||||
{{ tg.tag }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<div v-if="activeObsTag" class="obs-stats">
|
||||
<a-tag :color="activeObsTag.alive ? 'green' : 'red'">
|
||||
{{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }}
|
||||
</a-tag>
|
||||
<a-tag color="blue">{{ activeObsTag.delay }} ms</a-tag>
|
||||
<span class="obs-stamp">
|
||||
{{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }}
|
||||
</span>
|
||||
<span class="obs-stamp">
|
||||
{{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cpu-chart-wrap">
|
||||
<div class="cpu-chart-meta">
|
||||
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
||||
<span v-if="state.enabled && state.listen" class="listen-tag"> · {{ state.listen }}</span>
|
||||
</div>
|
||||
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
|
||||
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="null" :y-formatter="yFormatter" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bucket-select {
|
||||
width: 80px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.metrics-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-tabs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.obs-pane {
|
||||
padding: 4px 16px 0;
|
||||
}
|
||||
|
||||
.obs-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.obs-select {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.obs-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.obs-stamp {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.obs-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.obs-dot.is-alive {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.obs-dot.is-dead {
|
||||
background: #f5222d;
|
||||
}
|
||||
|
||||
.cpu-chart-wrap {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.cpu-chart-meta {
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.listen-tag {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
.xray-status-card .action {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
display: block;
|
||||
max-width: 400px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd';
|
||||
import {
|
||||
BarsOutlined,
|
||||
PoweroffOutlined,
|
||||
ReloadOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { Status } from '@/models/status';
|
||||
import './XrayStatusCard.css';
|
||||
|
||||
interface XrayStatusCardProps {
|
||||
status: Status;
|
||||
isMobile: boolean;
|
||||
ipLimitEnable: boolean;
|
||||
onStopXray: () => void;
|
||||
onRestartXray: () => void;
|
||||
onOpenLogs: () => void;
|
||||
onOpenXrayLogs: () => void;
|
||||
onOpenVersionSwitch: () => void;
|
||||
}
|
||||
|
||||
const XRAY_STATE_KEYS: Record<string, string> = {
|
||||
running: 'pages.index.xrayStatusRunning',
|
||||
stop: 'pages.index.xrayStatusStop',
|
||||
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,
|
||||
ipLimitEnable,
|
||||
onStopXray,
|
||||
onRestartXray,
|
||||
onOpenLogs,
|
||||
onOpenXrayLogs,
|
||||
onOpenVersionSwitch,
|
||||
}: XrayStatusCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
|
||||
|
||||
const title = (
|
||||
<Space>
|
||||
<span>{t('pages.index.xrayStatus')}</span>
|
||||
{isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
|
||||
<Tag color="green">v{status.xray.version}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const errorLines = useMemo(
|
||||
() => (status.xray.errorMsg || '').split('\n'),
|
||||
[status.xray.errorMsg],
|
||||
);
|
||||
|
||||
const extra =
|
||||
status.xray.state !== 'error' ? (
|
||||
<Badge
|
||||
status="processing"
|
||||
className={`xray-processing-animation ${badgeAnimationClass(status.xray.color)}`}
|
||||
text={stateText}
|
||||
color={status.xray.color}
|
||||
/>
|
||||
) : (
|
||||
<Popover
|
||||
title={
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<span>{t('pages.index.xrayStatusError')}</span>
|
||||
</Col>
|
||||
<Col>
|
||||
<BarsOutlined className="cursor-pointer" onClick={onOpenLogs} />
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
content={
|
||||
<>
|
||||
{errorLines.map((line, i) => (
|
||||
<span key={i} className="error-line">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
status="processing"
|
||||
text={stateText}
|
||||
color={status.xray.color}
|
||||
className="xray-processing-animation xray-error-animation"
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const actions = [
|
||||
...(ipLimitEnable
|
||||
? [
|
||||
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
|
||||
<BarsOutlined />
|
||||
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
||||
</Space>,
|
||||
]
|
||||
: []),
|
||||
<Space className="action" key="stop" onClick={onStopXray}>
|
||||
<PoweroffOutlined />
|
||||
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="restart" onClick={onRestartXray}>
|
||||
<ReloadOutlined />
|
||||
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="switch" onClick={onOpenVersionSwitch}>
|
||||
<ToolOutlined />
|
||||
{!isMobile && (
|
||||
<span>
|
||||
{status.xray.version && status.xray.version !== 'Unknown'
|
||||
? `v${status.xray.version}`
|
||||
: t('pages.index.xraySwitch')}
|
||||
</span>
|
||||
)}
|
||||
</Space>,
|
||||
];
|
||||
|
||||
return (
|
||||
<Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
BarsOutlined,
|
||||
PoweroffOutlined,
|
||||
ReloadOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['stop-xray', 'restart-xray', 'open-logs', 'open-xray-logs', 'open-version-switch']);
|
||||
|
||||
const XRAY_STATE_KEYS = {
|
||||
running: 'pages.index.xrayStatusRunning',
|
||||
stop: 'pages.index.xrayStatusStop',
|
||||
error: 'pages.index.xrayStatusError',
|
||||
};
|
||||
|
||||
const stateText = computed(() =>
|
||||
t(XRAY_STATE_KEYS[props.status.xray.state] ?? 'pages.index.xrayStatusUnknown'),
|
||||
);
|
||||
|
||||
function badgeAnimationClass(color) {
|
||||
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';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-space direction="horizontal">
|
||||
<span>{{ t('pages.index.xrayStatus') }}</span>
|
||||
<a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green">
|
||||
v{{ status.xray.version }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<template v-if="status.xray.state !== 'error'">
|
||||
<a-badge status="processing" :class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
|
||||
:text="stateText" :color="status.xray.color" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-popover>
|
||||
<template #title>
|
||||
<a-row type="flex" align="middle" justify="space-between">
|
||||
<a-col><span>{{ t('pages.index.xrayStatusError') }}</span></a-col>
|
||||
<a-col>
|
||||
<BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
<template #content>
|
||||
<span v-for="(line, i) in (status.xray.errorMsg || '').split('\n')" :key="i" class="error-line">
|
||||
{{ line }}
|
||||
</span>
|
||||
</template>
|
||||
<a-badge status="processing" :text="stateText" :color="status.xray.color"
|
||||
:class="['xray-processing-animation', 'xray-error-animation']" />
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')">
|
||||
<BarsOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" class="action" @click="$emit('stop-xray')">
|
||||
<PoweroffOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.stopXray') }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" class="action" @click="$emit('restart-xray')">
|
||||
<ReloadOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.restartXray') }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" class="action" @click="$emit('open-version-switch')">
|
||||
<ToolOutlined />
|
||||
<span v-if="!isMobile">
|
||||
{{ status.xray.version && status.xray.version !== 'Unknown'
|
||||
? `v${status.xray.version}`
|
||||
: t('pages.index.xraySwitch') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
display: block;
|
||||
max-width: 400px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Legacy xray-*-animation classes — they need to be global so they
|
||||
* pierce the AD-Vue badge's internal DOM (.ant-badge-status-*). */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
.login-app {
|
||||
--bg-page: #eef2ff;
|
||||
--bg-card: rgba(255, 255, 255, 0.72);
|
||||
--bg-card-solid: #ffffff;
|
||||
--color-text: rgba(0, 0, 0, 0.88);
|
||||
--color-text-subtle: rgba(0, 0, 0, 0.55);
|
||||
--color-accent: #6366f1;
|
||||
--color-border: rgba(255, 255, 255, 0.6);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 18px 50px rgba(99, 102, 241, 0.18);
|
||||
--blob-1: rgba(99, 102, 241, 0.55);
|
||||
--blob-2: rgba(236, 72, 153, 0.50);
|
||||
--blob-3: rgba(20, 184, 166, 0.40);
|
||||
--blob-4: rgba(251, 191, 36, 0.35);
|
||||
--blob-5: rgba(56, 189, 248, 0.40);
|
||||
--grid-color: rgba(99, 102, 241, 0.06);
|
||||
--vignette: radial-gradient(ellipse at center, transparent 30%, rgba(0, 0, 0, 0.05) 100%);
|
||||
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 50%, #ecfeff 100%);
|
||||
}
|
||||
|
||||
.login-app.is-dark {
|
||||
--bg-page: #0d1117;
|
||||
--bg-card: rgba(28, 30, 36, 0.55);
|
||||
--bg-card-solid: #1c1e22;
|
||||
--color-text: rgba(255, 255, 255, 0.92);
|
||||
--color-text-subtle: rgba(255, 255, 255, 0.55);
|
||||
--color-accent: #3b82f6;
|
||||
--color-border: rgba(255, 255, 255, 0.10);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 20px 60px rgba(59, 130, 246, 0.22);
|
||||
--blob-1: rgba(96, 165, 250, 0.42);
|
||||
--blob-2: rgba(59, 130, 246, 0.42);
|
||||
--blob-3: rgba(37, 99, 235, 0.35);
|
||||
--blob-4: rgba(251, 146, 60, 0.18);
|
||||
--blob-5: rgba(129, 140, 248, 0.30);
|
||||
--grid-color: rgba(255, 255, 255, 0.04);
|
||||
--vignette: radial-gradient(ellipse at center, transparent 25%, rgba(0, 0, 0, 0.5) 100%);
|
||||
background: radial-gradient(ellipse at 25% 20%, #152038 0%, #0d1117 60%);
|
||||
}
|
||||
|
||||
.login-app.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: rgba(15, 17, 28, 0.6);
|
||||
--bg-card-solid: #101013;
|
||||
--color-border: rgba(255, 255, 255, 0.06);
|
||||
--blob-1: rgba(99, 102, 241, 0.30);
|
||||
--blob-2: rgba(236, 72, 153, 0.22);
|
||||
--blob-3: rgba(34, 211, 238, 0.20);
|
||||
--blob-4: rgba(251, 146, 60, 0.15);
|
||||
--blob-5: rgba(168, 85, 247, 0.25);
|
||||
--grid-color: rgba(255, 255, 255, 0.025);
|
||||
--vignette: radial-gradient(ellipse at center, transparent 20%, rgba(0, 0, 0, 0.8) 100%);
|
||||
background: radial-gradient(ellipse at 25% 20%, #0a0a1a 0%, #000 60%);
|
||||
}
|
||||
|
||||
.login-app::before,
|
||||
.login-app::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70vw;
|
||||
height: 70vw;
|
||||
max-width: 900px;
|
||||
max-height: 900px;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-app::before {
|
||||
top: -25vw;
|
||||
left: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
|
||||
animation: blob-drift-a 24s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-app::after {
|
||||
bottom: -25vw;
|
||||
right: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
|
||||
animation: blob-drift-b 30s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
left: 50%;
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
max-width: 700px;
|
||||
max-height: 700px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
|
||||
filter: blur(100px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
animation: blob-drift-c 36s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
right: 15%;
|
||||
width: 35vw;
|
||||
height: 35vw;
|
||||
max-width: 500px;
|
||||
max-height: 500px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--blob-4) 0%, transparent 65%);
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
animation: blob-drift-d 28s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
left: 10%;
|
||||
width: 35vw;
|
||||
height: 35vw;
|
||||
max-width: 500px;
|
||||
max-height: 500px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--blob-5) 0%, transparent 65%);
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
animation: blob-drift-e 32s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
background-position: center;
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 30%, transparent 75%);
|
||||
mask-image: radial-gradient(ellipse at center, black 30%, transparent 75%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes blob-drift-a {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(18vw, 10vh) scale(1.15); }
|
||||
100% { transform: translate(34vw, 22vh) scale(1.25); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-b {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-16vw, -10vh) scale(1.12); }
|
||||
100% { transform: translate(-30vw, -22vh) scale(1.2); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-c {
|
||||
0% { transform: translate(-50%, -50%) scale(1); }
|
||||
50% { transform: translate(-20%, -20%) scale(1.1); }
|
||||
100% { transform: translate(-80%, -10%) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-d {
|
||||
0% { transform: translate(0, 0) scale(0.9); }
|
||||
50% { transform: translate(-12vw, 14vh) scale(1.05); }
|
||||
100% { transform: translate(8vw, -8vh) scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift-e {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(14vw, -8vh) scale(1.1); }
|
||||
100% { transform: translate(-6vw, 12vh) scale(1.15); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-app::before,
|
||||
.login-app::after,
|
||||
.login-content::before,
|
||||
.login-content::after,
|
||||
.login-wrapper::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-app .ant-layout-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-content > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toolbar-btn .anticon {
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.login-loading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 40px 32px 28px;
|
||||
box-shadow: var(--shadow-card);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 20px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.5),
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(99, 102, 241, 0.25) 80%
|
||||
);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-app.is-dark .login-card::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15),
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(59, 130, 246, 0.40) 80%
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 32px 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--color-text);
|
||||
background: linear-gradient(135deg, var(--color-accent), #ec4899);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.login-app.is-dark .brand-name {
|
||||
background: linear-gradient(135deg, #60a5fa, #1d4ed8);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.brand-accent {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--color-accent), #ec4899);
|
||||
}
|
||||
|
||||
.login-app.is-dark .brand-accent {
|
||||
background: linear-gradient(90deg, #60a5fa, #1d4ed8);
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
min-height: 42px;
|
||||
margin: 12px 0 28px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.welcome b {
|
||||
display: inline-block;
|
||||
font-weight: inherit;
|
||||
animation: headline-in 280ms ease both;
|
||||
}
|
||||
|
||||
@keyframes headline-in {
|
||||
0% { opacity: 0; transform: translateY(6px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.welcome b {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form .ant-form-item-label > label {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-form input.ant-input:-webkit-autofill,
|
||||
.login-form input.ant-input:-webkit-autofill:hover,
|
||||
.login-form input.ant-input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--color-text) !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--bg-card-solid) inset !important;
|
||||
box-shadow: 0 0 0 1000px var(--bg-card-solid) inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s, color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Form,
|
||||
Input,
|
||||
Layout,
|
||||
Popover,
|
||||
Spin,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
KeyOutlined,
|
||||
LockOutlined,
|
||||
TranslationOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import './LoginPage.css';
|
||||
|
||||
const HEADLINE_INTERVAL_MS = 2000;
|
||||
|
||||
interface LoginForm {
|
||||
username: string;
|
||||
password: string;
|
||||
twoFactorCode?: string;
|
||||
}
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
|
||||
const [headlineIndex, setHeadlineIndex] = useState(0);
|
||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||
|
||||
const headlineWords = useMemo(
|
||||
() => [t('pages.login.hello'), t('pages.login.title')],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setHeadlineIndex((i) => (i + 1) % headlineWords.length);
|
||||
}, HEADLINE_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [headlineWords.length]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
||||
if (cancelled) return;
|
||||
if (msg.success) setTwoFactorEnable(!!msg.obj);
|
||||
setFetched(true);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback(async (values: LoginForm) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/login', values);
|
||||
if (msg.success) window.location.href = basePath + 'panel/';
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onLangChange = useCallback((next: string) => {
|
||||
setLang(next);
|
||||
LanguageManager.setLanguage(next);
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
pauseAnimationsUntilLeave('login-theme-cycle');
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['login-app'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const langList = useMemo(
|
||||
() => LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[],
|
||||
[],
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<Layout.Content className="login-content">
|
||||
<div className="login-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
id="login-theme-cycle"
|
||||
className="theme-cycle"
|
||||
aria-label={t('menu.theme')}
|
||||
title={t('menu.theme')}
|
||||
onClick={cycleTheme}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
<Popover
|
||||
rootClassName={isDark ? 'dark' : 'light'}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
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>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('pages.settings.language')}
|
||||
icon={<TranslationOutlined />}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="login-wrapper">
|
||||
{!fetched ? (
|
||||
<div className="login-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="login-card">
|
||||
<div className="brand">
|
||||
<span className="brand-name">3X-UI</span>
|
||||
<span className="brand-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="welcome">
|
||||
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
|
||||
</h2>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
className="login-form"
|
||||
onFinish={onSubmit}
|
||||
initialValues={{ username: '', password: '', twoFactorCode: '' }}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('username')}
|
||||
name="username"
|
||||
rules={[{ required: true, message: t('username') }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
autoComplete="username"
|
||||
size="large"
|
||||
placeholder={t('username')}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('password')}
|
||||
name="password"
|
||||
rules={[{ required: true, message: t('password') }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
autoComplete="current-password"
|
||||
size="large"
|
||||
placeholder={t('password')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{twoFactorEnable && (
|
||||
<Form.Item
|
||||
label={t('twoFactorCode')}
|
||||
name="twoFactorCode"
|
||||
rules={[{ required: true, message: t('twoFactorCode') }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<KeyOutlined />}
|
||||
autoComplete="one-time-code"
|
||||
size="large"
|
||||
placeholder={t('twoFactorCode')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item className="submit-row">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import {
|
||||
antdThemeConfig,
|
||||
currentTheme,
|
||||
theme as themeState,
|
||||
toggleTheme,
|
||||
toggleUltra,
|
||||
pauseAnimationsUntilLeave,
|
||||
} from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetched = ref(false);
|
||||
const submitting = ref(false);
|
||||
const twoFactorEnable = ref(false);
|
||||
|
||||
const user = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
twoFactorCode: '',
|
||||
});
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
|
||||
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
|
||||
const HEADLINE_INTERVAL_MS = 2000;
|
||||
const headlineIndex = ref(0);
|
||||
let headlineTimer = null;
|
||||
|
||||
onMounted(() => {
|
||||
headlineTimer = window.setInterval(() => {
|
||||
headlineIndex.value = (headlineIndex.value + 1) % headlineWords.value.length;
|
||||
}, HEADLINE_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (headlineTimer != null) window.clearInterval(headlineTimer);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
||||
if (msg.success) twoFactorEnable.value = !!msg.obj;
|
||||
fetched.value = true;
|
||||
});
|
||||
|
||||
async function login() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/login', user);
|
||||
if (msg.success) window.location.href = basePath + 'panel/';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lang = ref(LanguageManager.getLanguage());
|
||||
function onLangChange(next) {
|
||||
LanguageManager.setLanguage(next);
|
||||
}
|
||||
|
||||
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
|
||||
* button uses, so the login chrome offers a one-click theme toggle
|
||||
* without the popover ceremony. */
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('login-theme-cycle');
|
||||
if (!themeState.isDark) {
|
||||
toggleTheme();
|
||||
if (themeState.isUltra) toggleUltra();
|
||||
} else if (!themeState.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<a-layout-content class="login-content">
|
||||
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
|
||||
plus a language picker hidden behind the gear popover. -->
|
||||
<div class="login-toolbar">
|
||||
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||
@click="cycleTheme">
|
||||
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="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>
|
||||
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="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 v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="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>
|
||||
</button>
|
||||
|
||||
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
|
||||
trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
|
||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="login-wrapper">
|
||||
<div v-if="!fetched" class="login-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else class="login-card">
|
||||
<div class="brand">
|
||||
<span class="brand-name">3X-UI</span>
|
||||
<span class="brand-accent" aria-hidden="true"></span>
|
||||
</div>
|
||||
<h2 class="welcome">
|
||||
<Transition name="headline" mode="out-in">
|
||||
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
||||
</Transition>
|
||||
</h2>
|
||||
|
||||
<a-form layout="vertical" class="login-form" @submit.prevent="login">
|
||||
<a-form-item :label="t('username')">
|
||||
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
|
||||
:placeholder="t('username')" autofocus required>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('password')">
|
||||
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
||||
size="large" :placeholder="t('password')" required>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
|
||||
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
||||
size="large" :placeholder="t('twoFactorCode')" required>
|
||||
<template #prefix>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item class="submit-row">
|
||||
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
|
||||
{{ submitting ? '' : t('login') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-app {
|
||||
--bg-page: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--color-text: rgba(0, 0, 0, 0.88);
|
||||
--color-text-subtle: rgba(0, 0, 0, 0.55);
|
||||
--color-accent: #1677ff;
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
--blob-1: rgba(99, 102, 241, 0.45);
|
||||
--blob-2: rgba(236, 72, 153, 0.38);
|
||||
--blob-3: rgba(20, 184, 166, 0.32);
|
||||
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.login-app.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
--color-text: rgba(255, 255, 255, 0.92);
|
||||
--color-text-subtle: rgba(255, 255, 255, 0.55);
|
||||
--color-accent: #4096ff;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--blob-1: rgba(64, 150, 255, 0.40);
|
||||
--blob-2: rgba(168, 85, 247, 0.34);
|
||||
--blob-3: rgba(34, 211, 238, 0.22);
|
||||
}
|
||||
|
||||
.login-app.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #141414;
|
||||
--color-border: rgba(255, 255, 255, 0.06);
|
||||
--blob-1: rgba(64, 150, 255, 0.22);
|
||||
--blob-2: rgba(168, 85, 247, 0.18);
|
||||
--blob-3: rgba(34, 211, 238, 0.12);
|
||||
}
|
||||
|
||||
/* Three blurred blobs slowly drift across the page; ::before and
|
||||
* ::after carry two of them, the third lives on .login-content::before
|
||||
* so we can animate it independently. */
|
||||
.login-app::before,
|
||||
.login-app::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70vw;
|
||||
height: 70vw;
|
||||
max-width: 900px;
|
||||
max-height: 900px;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-app::before {
|
||||
top: -25vw;
|
||||
left: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
|
||||
animation: blob-drift-a 24s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-app::after {
|
||||
bottom: -25vw;
|
||||
right: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
|
||||
animation: blob-drift-b 30s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
left: 50%;
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
max-width: 700px;
|
||||
max-height: 700px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
animation: blob-drift-c 36s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes blob-drift-a {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(18vw, 10vh) scale(1.15);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(34vw, 22vh) scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-drift-b {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-16vw, -10vh) scale(1.12);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-30vw, -22vh) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-drift-c {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20%, -20%) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-80%, -10%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.login-app::before,
|
||||
.login-app::after,
|
||||
.login-content::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-app :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-content>* {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.login-loading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 40px 32px 28px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 32px 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.brand-accent {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
min-height: 42px;
|
||||
margin: 12px 0 28px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.welcome b {
|
||||
display: inline-block;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.headline-enter-active,
|
||||
.headline-leave-active {
|
||||
transition: opacity 280ms ease, transform 280ms ease;
|
||||
}
|
||||
|
||||
.headline-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.headline-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item-label > label) {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-form :deep(input.ant-input:-webkit-autofill) {
|
||||
-webkit-text-fill-color: var(--color-text) !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
|
||||
box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s, color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-popover {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
.test-row .hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ant-form .hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.test-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { NodeRecord } from '@/hooks/useNodes';
|
||||
import './NodeFormModal.css';
|
||||
|
||||
type Mode = 'add' | 'edit';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface NodeFormModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
node: NodeRecord | null;
|
||||
testConnection: (payload: Partial<NodeRecord>) => Promise<ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
}>>;
|
||||
save: (payload: Partial<NodeRecord>) => Promise<ApiMsg>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
id: number;
|
||||
name: string;
|
||||
remark: string;
|
||||
scheme: 'http' | 'https';
|
||||
address: string;
|
||||
port: number;
|
||||
basePath: string;
|
||||
apiToken: string;
|
||||
enable: boolean;
|
||||
allowPrivateAddress: boolean;
|
||||
}
|
||||
|
||||
function defaultForm(): FormState {
|
||||
return {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function NodeFormModal({
|
||||
open,
|
||||
mode,
|
||||
node,
|
||||
testConnection,
|
||||
save,
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const [form, setForm] = useState<FormState>(defaultForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const base = defaultForm();
|
||||
const next: FormState = mode === 'edit' && node
|
||||
? {
|
||||
...base,
|
||||
...(node as unknown as Partial<FormState>),
|
||||
id: node.id,
|
||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||
}
|
||||
: base;
|
||||
|
||||
setForm(next);
|
||||
setTestResult(null);
|
||||
|
||||
}, [open, mode, node]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
function buildPayload(): Partial<NodeRecord> {
|
||||
return {
|
||||
id: form.id || 0,
|
||||
name: form.name?.trim() || '',
|
||||
remark: form.remark?.trim() || '',
|
||||
scheme: form.scheme || 'https',
|
||||
address: form.address?.trim() || '',
|
||||
port: Number(form.port) || 0,
|
||||
basePath: form.basePath?.trim() || '/',
|
||||
apiToken: form.apiToken?.trim() || '',
|
||||
enable: !!form.enable,
|
||||
allowPrivateAddress: !!form.allowPrivateAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload.address || !payload.port) {
|
||||
messageApi.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
const msg = await testConnection(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
setTestResult(msg.obj);
|
||||
} else {
|
||||
setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' });
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const payload = buildPayload();
|
||||
if (!payload.name || !payload.address || !payload.port) {
|
||||
messageApi.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const msg = await save(payload);
|
||||
if (msg?.success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!submitting) onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
confirmLoading={submitting}
|
||||
okText={t('save')}
|
||||
cancelText={t('cancel')}
|
||||
mask={{ closable: false }}
|
||||
width="640px"
|
||||
onOk={onSave}
|
||||
onCancel={close}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.name')} required>
|
||||
<Input
|
||||
value={form.name}
|
||||
placeholder={t('pages.nodes.namePlaceholder')}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.remark')}>
|
||||
<Input value={form.remark} onChange={(e) => update('remark', e.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item label={t('pages.nodes.scheme')}>
|
||||
<Select
|
||||
value={form.scheme}
|
||||
onChange={(v) => update('scheme', v)}
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.address')} required>
|
||||
<Input
|
||||
value={form.address}
|
||||
placeholder={t('pages.nodes.addressPlaceholder')}
|
||||
onChange={(e) => update('address', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item label={t('pages.nodes.port')} required>
|
||||
<InputNumber
|
||||
value={form.port}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => update('port', Number(v) || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.basePath')}>
|
||||
<Input
|
||||
value={form.basePath}
|
||||
placeholder="/"
|
||||
onChange={(e) => update('basePath', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.enable')}>
|
||||
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label={t('pages.nodes.allowPrivateAddress')}>
|
||||
<Switch
|
||||
checked={form.allowPrivateAddress}
|
||||
onChange={(v) => update('allowPrivateAddress', v)}
|
||||
/>
|
||||
<div className="hint">{t('pages.nodes.allowPrivateAddressHint')}</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.nodes.apiToken')} required>
|
||||
<Input.Password
|
||||
value={form.apiToken}
|
||||
placeholder={t('pages.nodes.apiTokenPlaceholder')}
|
||||
onChange={(e) => update('apiToken', e.target.value)}
|
||||
/>
|
||||
<div className="hint">{t('pages.nodes.apiTokenHint')}</div>
|
||||
</Form.Item>
|
||||
|
||||
<div className="test-row">
|
||||
<Button type="default" loading={testing} onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add' }, // 'add' | 'edit'
|
||||
node: { type: Object, default: null },
|
||||
testConnection: { type: Function, required: true },
|
||||
save: { type: Function, required: true }, // (payload) => Promise<msg>
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Default form shape — used for "add" mode and to reset between
|
||||
// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
const submitting = ref(false);
|
||||
const testing = ref(false);
|
||||
const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
|
||||
// Reset the form whenever the modal is opened. In edit mode we copy
|
||||
// the existing node into the form fields; in add mode we wipe back
|
||||
// to defaults so a previous edit doesn't leak through.
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
Object.assign(form, defaultForm());
|
||||
testResult.value = null;
|
||||
if (props.mode === 'edit' && props.node) {
|
||||
Object.assign(form, props.node);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const title = computed(() =>
|
||||
props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
|
||||
);
|
||||
|
||||
function close() {
|
||||
if (!submitting.value) emit('update:open', false);
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
id: form.id || 0,
|
||||
name: form.name?.trim() || '',
|
||||
remark: form.remark?.trim() || '',
|
||||
scheme: form.scheme || 'https',
|
||||
address: form.address?.trim() || '',
|
||||
port: Number(form.port) || 0,
|
||||
basePath: form.basePath?.trim() || '/',
|
||||
apiToken: form.apiToken?.trim() || '',
|
||||
enable: !!form.enable,
|
||||
allowPrivateAddress: !!form.allowPrivateAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
testing.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload.address || !payload.port) {
|
||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
const msg = await props.testConnection(payload);
|
||||
if (msg?.success) {
|
||||
testResult.value = msg.obj;
|
||||
} else {
|
||||
testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const payload = buildPayload();
|
||||
if (!payload.name || !payload.address || !payload.port) {
|
||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const msg = await props.save(payload);
|
||||
if (msg?.success) {
|
||||
emit('update:open', false);
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :confirm-loading="submitting" :ok-text="t('save')" :cancel-text="t('cancel')"
|
||||
:mask-closable="false" width="640px" @ok="onSave" @cancel="close">
|
||||
<a-form layout="vertical" :model="form">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.name')" required>
|
||||
<a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.remark')">
|
||||
<a-input v-model:value="form.remark" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item :label="t('pages.nodes.scheme')">
|
||||
<a-select v-model:value="form.scheme">
|
||||
<a-select-option value="https">https</a-select-option>
|
||||
<a-select-option value="http">http</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.address')" required>
|
||||
<a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item :label="t('pages.nodes.port')" required>
|
||||
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.basePath')">
|
||||
<a-input v-model:value="form.basePath" placeholder="/" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.enable')">
|
||||
<a-switch v-model:checked="form.enable" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item :label="t('pages.nodes.allowPrivateAddress')">
|
||||
<a-switch v-model:checked="form.allowPrivateAddress" />
|
||||
<div class="hint">{{ t('pages.nodes.allowPrivateAddressHint') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.nodes.apiToken')" required>
|
||||
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<div class="test-row">
|
||||
<a-button :loading="testing" @click="onTest">
|
||||
{{ t('pages.nodes.testConnection') }}
|
||||
</a-button>
|
||||
<div v-if="testResult" class="test-result">
|
||||
<a-alert v-if="testResult.status === 'online'" type="success" show-icon
|
||||
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
|
||||
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined" />
|
||||
<a-alert v-else type="error" show-icon :message="t('pages.nodes.connectionFailed')"
|
||||
:description="testResult.error" />
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.test-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
.node-history-panel {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.node-history-panel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.node-history-panel .series-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline';
|
||||
import './NodeHistoryPanel.css';
|
||||
|
||||
interface NodeRef {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface NodeHistoryPanelProps {
|
||||
node: NodeRef;
|
||||
bucket?: number;
|
||||
}
|
||||
|
||||
interface SeriesPoint {
|
||||
t: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
|
||||
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cpuPoints, setCpuPoints] = useState<number[]>([]);
|
||||
const [cpuLabels, setCpuLabels] = useState<string[]>([]);
|
||||
const [memPoints, setMemPoints] = useState<number[]>([]);
|
||||
const [memLabels, setMemLabels] = useState<string[]>([]);
|
||||
|
||||
const lastNodeId = useRef<number>(node.id);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const bucketLabel = (unixSec: number) => {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
if (bucket >= 60) return `${hh}:${mm}`;
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
};
|
||||
|
||||
const fetchSeries = async (metric: 'cpu' | 'mem') => {
|
||||
try {
|
||||
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
|
||||
const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals: number[] = [];
|
||||
const labs: string[] = [];
|
||||
for (const p of msg.obj) {
|
||||
labs.push(bucketLabel(p.t));
|
||||
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
|
||||
}
|
||||
return { vals, labs };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('node history fetch failed', metric, e);
|
||||
}
|
||||
return { vals: [] as number[], labs: [] as string[] };
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
||||
if (cancelled) return;
|
||||
setCpuPoints(cpu.vals);
|
||||
setCpuLabels(cpu.labs);
|
||||
setMemPoints(mem.vals);
|
||||
setMemLabels(mem.labs);
|
||||
};
|
||||
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, REFRESH_MS);
|
||||
lastNodeId.current = node.id;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [node.id, bucket]);
|
||||
|
||||
return (
|
||||
<div className="node-history-panel">
|
||||
<div className="series">
|
||||
<div className="series-title">{t('pages.nodes.cpu')}</div>
|
||||
<Sparkline
|
||||
data={cpuPoints}
|
||||
labels={cpuLabels}
|
||||
vbWidth={640}
|
||||
height={120}
|
||||
stroke="#008771"
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={4}
|
||||
maxPoints={cpuPoints.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
<div className="series">
|
||||
<div className="series-title">{t('pages.nodes.mem')}</div>
|
||||
<Sparkline
|
||||
data={memPoints}
|
||||
labels={memLabels}
|
||||
vbWidth={640}
|
||||
height={120}
|
||||
stroke="#7c4dff"
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={4}
|
||||
maxPoints={memPoints.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
// Bucket size in seconds — matches the SystemHistoryModal selector.
|
||||
bucket: { type: Number, default: 30 },
|
||||
});
|
||||
|
||||
// Two parallel series so the panel renders CPU and Mem side-by-side
|
||||
// in a single fetch round-trip per refresh.
|
||||
const cpuPoints = ref([]);
|
||||
const cpuLabels = ref([]);
|
||||
const memPoints = ref([]);
|
||||
const memLabels = ref([]);
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
let timer = null;
|
||||
|
||||
function bucketLabel(unixSec) {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
if (props.bucket >= 60) return `${hh}:${mm}`;
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
async function fetchSeries(metric) {
|
||||
try {
|
||||
const url = `/panel/api/nodes/history/${props.node.id}/${metric}/${props.bucket}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals = [];
|
||||
const labs = [];
|
||||
for (const p of msg.obj) {
|
||||
labs.push(bucketLabel(p.t));
|
||||
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
|
||||
}
|
||||
return { vals, labs };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('node history fetch failed', metric, e);
|
||||
}
|
||||
return { vals: [], labs: [] };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
||||
cpuPoints.value = cpu.vals;
|
||||
cpuLabels.value = cpu.labs;
|
||||
memPoints.value = mem.vals;
|
||||
memLabels.value = mem.labs;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, REFRESH_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
// If the parent table re-emits a node row with a different id (rare —
|
||||
// happens when the list is sorted or filtered while the panel is open),
|
||||
// reset and re-fetch.
|
||||
watch(() => props.node?.id, (a, b) => {
|
||||
if (a !== b) refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="node-history-panel">
|
||||
<div class="series">
|
||||
<div class="series-title">{{ t('pages.nodes.cpu') }}</div>
|
||||
<Sparkline :data="cpuPoints" :labels="cpuLabels" :vb-width="640" :height="120" stroke="#008771" :show-grid="true"
|
||||
:show-axes="true" :tick-count-x="4" :max-points="cpuPoints.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="2.6" :show-tooltip="true" />
|
||||
</div>
|
||||
<div class="series">
|
||||
<div class="series-title">{{ t('pages.nodes.mem') }}</div>
|
||||
<Sparkline :data="memPoints" :labels="memLabels" :vb-width="640" :height="120" stroke="#7c4dff" :show-grid="true"
|
||||
:show-axes="true" :tick-count-x="4" :max-points="memPoints.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="2.6" :show-tooltip="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.node-history-panel {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.node-history-panel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.series-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name-cell .name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.name-cell .remark {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ip-toggle-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.address-hidden {
|
||||
filter: blur(5px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.address-visible {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.node-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
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;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-expand {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
transition: transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 20px 0;
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||
import type { NodeRecord } from '@/hooks/useNodes';
|
||||
import './NodeList.css';
|
||||
|
||||
interface NodeListProps {
|
||||
nodes: NodeRecord[];
|
||||
loading?: boolean;
|
||||
isMobile?: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (node: NodeRecord) => void;
|
||||
onDelete: (node: NodeRecord) => void;
|
||||
onProbe: (node: NodeRecord) => void;
|
||||
onToggleEnable: (node: NodeRecord, next: boolean) => void;
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: number;
|
||||
}
|
||||
|
||||
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||
switch (status) {
|
||||
case 'online': return 'success';
|
||||
case 'offline': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPct(p?: number): string {
|
||||
if (typeof p !== 'number' || Number.isNaN(p)) return '-';
|
||||
return `${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatUptime(secs?: number): string {
|
||||
if (!secs) return '-';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
const mins = Math.floor((secs % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function useRelativeTime() {
|
||||
const { t } = useTranslation();
|
||||
return (unixSeconds?: number) => {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
};
|
||||
}
|
||||
|
||||
export default function NodeList({
|
||||
nodes,
|
||||
loading = false,
|
||||
isMobile = false,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onProbe,
|
||||
onToggleEnable,
|
||||
}: NodeListProps) {
|
||||
const { t } = useTranslation();
|
||||
const relativeTime = useRelativeTime();
|
||||
|
||||
const [showAddress, setShowAddress] = useState(false);
|
||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const dataSource = useMemo<NodeRow[]>(
|
||||
() => nodes.map((n) => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.id,
|
||||
})),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnsType<NodeRow>>(() => [
|
||||
{
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 160,
|
||||
render: (_value, record) => (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<div className="name-cell">
|
||||
<span className="name">{record.name}</span>
|
||||
{record.remark && <span className="remark">{record.remark}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span className="address-header">
|
||||
{t('pages.nodes.address')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.status'),
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<Space size={4}>
|
||||
<Badge status={badgeStatus(record.status)} />
|
||||
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
|
||||
{record.lastError && (
|
||||
<Tooltip title={record.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.cpu'),
|
||||
dataIndex: 'cpuPct',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (_value, record) => formatPct(record.cpuPct),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.mem'),
|
||||
dataIndex: 'memPct',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (_value, record) => formatPct(record.memPct),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.xrayVersion'),
|
||||
dataIndex: 'xrayVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => record.xrayVersion || '-',
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => record.panelVersion || '-',
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.uptime'),
|
||||
dataIndex: 'uptimeSecs',
|
||||
align: 'center',
|
||||
render: (_value, record) => formatUptime(record.uptimeSecs),
|
||||
},
|
||||
{
|
||||
title: t('clients'),
|
||||
align: 'center',
|
||||
width: 160,
|
||||
render: (_value, record) => (
|
||||
<Space size={4}>
|
||||
<Tag color="green">{record.clientCount || 0}</Tag>
|
||||
{record.onlineCount ? (
|
||||
<Tag color="blue">{record.onlineCount} {t('online')}</Tag>
|
||||
) : null}
|
||||
{record.depletedCount ? (
|
||||
<Tag color="red">{record.depletedCount} {t('depleted')}</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.latency'),
|
||||
dataIndex: 'latencyMs',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (_value, record) =>
|
||||
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.lastHeartbeat'),
|
||||
dataIndex: 'lastHeartbeat',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable>
|
||||
<div className="toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{t('pages.nodes.addNode')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className="node-cards">
|
||||
{dataSource.length === 0 ? (
|
||||
<div className="card-empty">—</div>
|
||||
) : (
|
||||
dataSource.map((record) => (
|
||||
<div key={record.id} className="node-card">
|
||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
||||
<Badge status={badgeStatus(record.status)} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
onClick={() => setStatsNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: <><EditOutlined /> {t('edit')}</>,
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: <><DeleteOutlined /> {t('delete')}</>,
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={!!statsNode}
|
||||
footer={null}
|
||||
width={360}
|
||||
centered
|
||||
title={statsNode?.name || ''}
|
||||
onCancel={() => setStatsNode(null)}
|
||||
>
|
||||
{statsNode && (
|
||||
<div className="card-stats">
|
||||
{statsNode.remark && (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.name')}</span>
|
||||
<span>{statsNode.remark}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.address')}</span>
|
||||
<a
|
||||
href={statsNode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{statsNode.url}
|
||||
</a>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.status')}</span>
|
||||
<Badge status={badgeStatus(statsNode.status)} />
|
||||
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
|
||||
{statsNode.lastError && (
|
||||
<Tooltip title={statsNode.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.cpu')}</span>
|
||||
<Tag>{formatPct(statsNode.cpuPct)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.mem')}</span>
|
||||
<Tag>{formatPct(statsNode.memPct)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
|
||||
<Tag>{statsNode.xrayVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.panelVersion') || 'Panel version'}</span>
|
||||
<Tag>{statsNode.panelVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.uptime')}</span>
|
||||
<Tag>{formatUptime(statsNode.uptimeSecs)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.latency')}</span>
|
||||
<Tag>
|
||||
{statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('clients')}</span>
|
||||
<Tag color="green">{statsNode.clientCount || 0}</Tag>
|
||||
{statsNode.onlineCount ? (
|
||||
<Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
|
||||
) : null}
|
||||
{statsNode.depletedCount ? (
|
||||
<Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.lastHeartbeat')}</span>
|
||||
<Tag>{relativeTime(statsNode.lastHeartbeat)}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
) : (
|
||||
<Table<NodeRow>
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="id"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'add',
|
||||
'edit',
|
||||
'delete',
|
||||
'probe',
|
||||
'toggle-enable',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dataSource = computed(() =>
|
||||
props.nodes.map((n) => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const showAddress = ref(false);
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'online': return 'green';
|
||||
case 'offline': return 'red';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// Relative-time formatter — keeps the column compact and avoids
|
||||
// pulling dayjs just for this single use.
|
||||
function relativeTime(unixSeconds) {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
}
|
||||
|
||||
function formatUptime(secs) {
|
||||
if (!secs) return '-';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
const mins = Math.floor((secs % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function formatPct(p) {
|
||||
if (typeof p !== 'number' || isNaN(p)) return '-';
|
||||
return `${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const statsNode = ref(null);
|
||||
function openStats(node) {
|
||||
statsNode.value = node;
|
||||
}
|
||||
function closeStats() {
|
||||
statsNode.value = null;
|
||||
}
|
||||
|
||||
const expandedIds = ref(new Set());
|
||||
function toggleExpanded(id) {
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds.value = next;
|
||||
}
|
||||
function isExpanded(id) {
|
||||
return expandedIds.value.has(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card size="small" hoverable>
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" @click="emit('add')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.nodes.addNode') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<div v-if="isMobile" class="node-cards">
|
||||
<div v-if="dataSource.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in dataSource" :key="record.id" class="node-card">
|
||||
<div class="card-head" @click="toggleExpanded(record.id)">
|
||||
<RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||
<a-badge
|
||||
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||
<span class="node-name">{{ record.name }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="probe" @click="emit('probe', record)">
|
||||
<ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="edit" @click="emit('edit', record)">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(record.id)" class="card-history">
|
||||
<NodeHistoryPanel :node="record" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
|
||||
:title="statsNode ? statsNode.name : ''" @cancel="closeStats">
|
||||
<div v-if="statsNode" class="card-stats">
|
||||
<div v-if="statsNode.remark" class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.name') }}</span>
|
||||
<span>{{ statsNode.remark }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.address') }}</span>
|
||||
<a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
|
||||
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showAddress = !showAddress" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.status') }}</span>
|
||||
<a-badge
|
||||
:status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
|
||||
<span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
|
||||
<a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
|
||||
<ExclamationCircleOutlined style="color: #faad14" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
|
||||
<a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.mem') }}</span>
|
||||
<a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
|
||||
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.panelVersion') || 'Panel version' }}</span>
|
||||
<a-tag>{{ statsNode.panelVersion || '-' }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
|
||||
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.latency') }}</span>
|
||||
<a-tag>
|
||||
<template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
|
||||
<template v-else>-</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('clients') }}</span>
|
||||
<a-tag color="green">{{ statsNode.clientCount || 0 }}</a-tag>
|
||||
<a-tag v-if="statsNode.onlineCount" color="blue">
|
||||
{{ statsNode.onlineCount }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="statsNode.depletedCount" color="red">
|
||||
{{ statsNode.depletedCount }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
|
||||
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
|
||||
size="middle" row-key="id">
|
||||
<template #expandedRowRender="{ record }">
|
||||
<NodeHistoryPanel :node="record" />
|
||||
</template>
|
||||
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
|
||||
<template #default="{ record }">
|
||||
<div class="name-cell">
|
||||
<span class="name">{{ record.name }}</span>
|
||||
<span v-if="record.remark" class="remark">{{ record.remark }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column data-index="url" :ellipsis="true">
|
||||
<template #title>
|
||||
<span class="address-header">
|
||||
{{ t('pages.nodes.address') }}
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showAddress = !showAddress" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ record }">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer"
|
||||
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ record.url }}</a>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
|
||||
<template #default="{ record }">
|
||||
<a-space :size="4">
|
||||
<a-badge
|
||||
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||
<span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
|
||||
<a-tooltip v-if="record.lastError" :title="record.lastError">
|
||||
<ExclamationCircleOutlined style="color: #faad14" />
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.cpu')" data-index="cpuPct" align="center" :width="90">
|
||||
<template #default="{ record }">{{ formatPct(record.cpuPct) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.mem')" data-index="memPct" align="center" :width="90">
|
||||
<template #default="{ record }">{{ formatPct(record.memPct) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.xrayVersion')" data-index="xrayVersion" align="center">
|
||||
<template #default="{ record }">
|
||||
{{ record.xrayVersion || '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.panelVersion') || 'Panel version'" data-index="panelVersion" align="center">
|
||||
<template #default="{ record }">
|
||||
{{ record.panelVersion || '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
|
||||
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('clients')" align="center" :width="160">
|
||||
<template #default="{ record }">
|
||||
<a-space :size="4">
|
||||
<a-tag color="green">{{ record.clientCount || 0 }}</a-tag>
|
||||
<a-tag v-if="record.onlineCount" color="blue">
|
||||
{{ record.onlineCount }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="record.depletedCount" color="red">
|
||||
{{ record.depletedCount }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
|
||||
<template #default="{ record }">
|
||||
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.lastHeartbeat')" data-index="lastHeartbeat" align="center" :width="120">
|
||||
<template #default="{ record }">{{ relativeTime(record.lastHeartbeat) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
|
||||
<template #default="{ record }">
|
||||
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.actions')" align="center" :width="160" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-tooltip :title="t('pages.nodes.probe')">
|
||||
<a-button type="text" size="small" @click="emit('probe', record)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<a-button type="text" size="small" @click="emit('edit', record)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('delete')">
|
||||
<a-button type="text" size="small" danger @click="emit('delete', record)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remark {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ip-toggle-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.address-hidden {
|
||||
filter: blur(5px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.address-visible {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.node-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:global(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;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-expand {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
transition: transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CloudServerOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useNodes } from '@/hooks/useNodes';
|
||||
import type { NodeRecord } from '@/hooks/useNodes';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
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';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
} = useNodes();
|
||||
|
||||
useWebSocket({ nodes: applyNodesEvent });
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
setFormNode(null);
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const onEdit = useCallback((node: NodeRecord) => {
|
||||
setFormMode('edit');
|
||||
setFormNode({ ...node });
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
if (formMode === 'edit' && formNode?.id) {
|
||||
return update(formNode.id, payload);
|
||||
}
|
||||
return create(payload);
|
||||
}, [formMode, formNode, update, create]);
|
||||
|
||||
const onDelete = useCallback((node: NodeRecord) => {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
|
||||
content: t('pages.nodes.deleteConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(node.id);
|
||||
if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
|
||||
},
|
||||
});
|
||||
}, [modal, t, remove, messageApi]);
|
||||
|
||||
const onProbe = useCallback(async (node: NodeRecord) => {
|
||||
const msg = await probe(node.id);
|
||||
if (msg?.success && msg.obj) {
|
||||
if (msg.obj.status === 'online') {
|
||||
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||
} else {
|
||||
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||
}
|
||||
}
|
||||
}, [probe, t, messageApi]);
|
||||
|
||||
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
|
||||
await setEnable(node.id, next);
|
||||
}, [setEnable]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['nodes-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description="Loading…" size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||
<Col span={24}>
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, isMobile ? 16 : 12]}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.totalNodes')}
|
||||
value={String(totals.total)}
|
||||
prefix={<CloudServerOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.onlineNodes')}
|
||||
value={String(totals.online)}
|
||||
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.offlineNodes')}
|
||||
value={String(totals.offline)}
|
||||
prefix={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.avgLatency')}
|
||||
value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<NodeList
|
||||
nodes={nodes}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onProbe={onProbe}
|
||||
onToggleEnable={onToggleEnable}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<NodeFormModal
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
node={formNode}
|
||||
testConnection={testConnection}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import NodeList from './NodeList.vue';
|
||||
import NodeFormModal from './NodeFormModal.vue';
|
||||
import { useNodes } from './useNodes.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
} = useNodes();
|
||||
|
||||
// Live updates — NodeHeartbeatJob pushes the fresh list every 10s.
|
||||
useWebSocket({ nodes: applyNodesEvent });
|
||||
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// === Form modal state =================================================
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const formNode = ref(null);
|
||||
|
||||
function onAdd() {
|
||||
formMode.value = 'add';
|
||||
formNode.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEdit(node) {
|
||||
formMode.value = 'edit';
|
||||
formNode.value = { ...node };
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
// Save callback the modal hands its payload to. We hide the create vs.
|
||||
// update branching here so the modal stays mode-agnostic.
|
||||
async function onSave(payload) {
|
||||
if (formMode.value === 'edit' && formNode.value?.id) {
|
||||
return update(formNode.value.id, payload);
|
||||
}
|
||||
return create(payload);
|
||||
}
|
||||
|
||||
function onDelete(node) {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
|
||||
content: t('pages.nodes.deleteConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(node.id);
|
||||
if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onProbe(node) {
|
||||
const msg = await probe(node.id);
|
||||
if (msg?.success && msg.obj) {
|
||||
if (msg.obj.status === 'online') {
|
||||
message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||
} else {
|
||||
message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleEnable(node, next) {
|
||||
await setEnable(node.id, next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="nodes-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, isMobile ? 16 : 12]">
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
||||
<template #prefix>
|
||||
<CloudServerOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined style="color: #52c41a" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
||||
<template #prefix>
|
||||
<CloseCircleOutlined style="color: #ff4d4f" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
||||
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
||||
<template #prefix>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Node table -->
|
||||
<a-col :span="24">
|
||||
<NodeList :nodes="nodes" :loading="loading" :is-mobile="isMobile" @add="onAdd" @edit="onEdit"
|
||||
@delete="onDelete" @probe="onProbe" @toggle-enable="onToggleEnable" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<NodeFormModal v-model:open="formOpen" :mode="formMode" :node="formNode" :test-connection="testConnection"
|
||||
:save="onSave" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nodes-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.nodes-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.nodes-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.nodes-page :deep(.ant-layout),
|
||||
.nodes-page :deep(.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,130 +0,0 @@
|
||||
// Loads the node list and runs CRUD/probe actions against the
|
||||
// /panel/api/nodes/* endpoints. Live updates arrive over WebSocket
|
||||
// (pushed by NodeHeartbeatJob every 10s) so we don't poll.
|
||||
|
||||
import { computed, onMounted, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export function useNodes() {
|
||||
const nodes = shallowRef([]);
|
||||
const loading = ref(false);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||
if (msg?.success) {
|
||||
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces the local list with the snapshot pushed by the heartbeat job.
|
||||
// shallowRef means a fresh assignment is enough to retrigger reactivity;
|
||||
// we always assign a new array so Vue notices.
|
||||
function applyNodesEvent(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
nodes.value = payload;
|
||||
if (!fetched.value) fetched.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/add', payload);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function update(id, payload) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function setEnable(id, enable) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable });
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
// testConnection probes a transient (unsaved) node config so the form
|
||||
// can validate before save. Returns the ProbeResultUI shape from Go.
|
||||
async function testConnection(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// probe forces an immediate heartbeat against an already-saved node.
|
||||
async function probe(id) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
const totals = computed(() => {
|
||||
const list = nodes.value;
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let inbounds = 0;
|
||||
let clients = 0;
|
||||
let onlineClients = 0;
|
||||
let depleted = 0;
|
||||
for (const n of list) {
|
||||
inbounds += n.inboundCount || 0;
|
||||
clients += n.clientCount || 0;
|
||||
onlineClients += n.onlineCount || 0;
|
||||
depleted += n.depletedCount || 0;
|
||||
if (!n.enable) continue;
|
||||
if (n.status === 'online') {
|
||||
online += 1;
|
||||
if (n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: list.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
inbounds,
|
||||
clients,
|
||||
onlineClients,
|
||||
depleted,
|
||||
};
|
||||
});
|
||||
|
||||
// Initial fetch — WebSocket takes over after the first heartbeat tick
|
||||
// (~10s) but the page should populate immediately on mount.
|
||||
onMounted(refresh);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
refresh,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Collapse,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import SettingListItem from '@/components/SettingListItem';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface GeneralTabProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
|
||||
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
|
||||
const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
|
||||
{ name: 'Gregorian (Standard)', value: 'gregorian' },
|
||||
{ name: 'Jalalian (شمسی)', value: 'jalalian' },
|
||||
];
|
||||
|
||||
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list') as ApiMsg<{
|
||||
tag: string; protocol: string; port: number;
|
||||
}[]>;
|
||||
if (cancelled) return;
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
setInboundOptions(msg.obj.map((ib) => ({
|
||||
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
|
||||
value: ib.tag,
|
||||
})));
|
||||
} else {
|
||||
setInboundOptions([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const remarkModel = useMemo(() => {
|
||||
const rm = allSetting.remarkModel || '';
|
||||
return rm.length > 1 ? rm.substring(1).split('') : [];
|
||||
}, [allSetting.remarkModel]);
|
||||
|
||||
const remarkSeparator = useMemo(() => {
|
||||
const rm = allSetting.remarkModel || '-';
|
||||
return rm.length > 1 ? rm.charAt(0) : '-';
|
||||
}, [allSetting.remarkModel]);
|
||||
|
||||
const remarkSample = useMemo(() => {
|
||||
const parts = remarkModel.map((k) => REMARK_MODELS[k]);
|
||||
return parts.length === 0 ? '' : parts.join(remarkSeparator);
|
||||
}, [remarkModel, remarkSeparator]);
|
||||
|
||||
function setRemarkModel(parts: string[]) {
|
||||
updateSetting({ remarkModel: remarkSeparator + parts.join('') });
|
||||
}
|
||||
|
||||
function setRemarkSeparator(sep: string) {
|
||||
const tail = (allSetting.remarkModel || '-').substring(1);
|
||||
updateSetting({ remarkModel: sep + tail });
|
||||
}
|
||||
|
||||
const ldapInboundTagList = useMemo(() => {
|
||||
const csv = allSetting.ldapInboundTags || '';
|
||||
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
}, [allSetting.ldapInboundTags]);
|
||||
|
||||
function setLdapInboundTagList(list: string[]) {
|
||||
updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' });
|
||||
}
|
||||
|
||||
function onLangChange(value: string) {
|
||||
setLang(value);
|
||||
LanguageManager.setLanguage(value);
|
||||
}
|
||||
|
||||
const langOptions = useMemo(
|
||||
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
||||
value: l.value,
|
||||
label: (
|
||||
<>
|
||||
<span role="img" aria-label={l.name}>{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse defaultActiveKey="1" items={[
|
||||
{
|
||||
key: '1',
|
||||
label: t('pages.settings.panelSettings'),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.remarkModel')}
|
||||
description={<>{t('pages.settings.sampleRemark')}: <i>#{remarkSample}</i></>}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={remarkModel}
|
||||
onChange={setRemarkModel}
|
||||
style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
|
||||
options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
|
||||
/>
|
||||
<Select
|
||||
value={remarkSeparator}
|
||||
onChange={setRemarkSeparator}
|
||||
style={{ width: '20%' }}
|
||||
options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
|
||||
<Input value={allSetting.webListen} onChange={(e) => updateSetting({ webListen: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelListeningDomain')} description={t('pages.settings.panelListeningDomainDesc')}>
|
||||
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
|
||||
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
|
||||
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||
<InputNumber value={allSetting.sessionMaxAge} min={60} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title="Trusted proxy CIDRs"
|
||||
description="Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers."
|
||||
>
|
||||
<Input
|
||||
value={allSetting.trustedProxyCIDRs}
|
||||
placeholder="127.0.0.1/32,::1/128"
|
||||
onChange={(e) => updateSetting({ trustedProxyCIDRs: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
|
||||
<InputNumber value={allSetting.pageSize} min={0} step={5} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.language')}>
|
||||
<Select
|
||||
value={lang}
|
||||
onChange={onLangChange}
|
||||
style={{ width: '100%' }}
|
||||
options={langOptions}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('pages.settings.notifications'),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
||||
<InputNumber value={allSetting.trafficDiff} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('pages.settings.certs'),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}>
|
||||
<Input value={allSetting.webCertFile} onChange={(e) => updateSetting({ webCertFile: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.privateKeyPath')} description={t('pages.settings.privateKeyPathDesc')}>
|
||||
<Input value={allSetting.webKeyFile} onChange={(e) => updateSetting({ webKeyFile: e.target.value })} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
label: t('pages.settings.externalTraffic'),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}>
|
||||
<Switch checked={allSetting.externalTrafficInformEnable}
|
||||
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformURI')} description={t('pages.settings.externalTrafficInformURIDesc')}>
|
||||
<Input
|
||||
value={allSetting.externalTrafficInformURI}
|
||||
placeholder="(http|https)://domain[:port]/path/"
|
||||
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
||||
<Switch checked={allSetting.restartXrayOnClientDisable}
|
||||
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
label: t('pages.settings.dateAndTime'),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}>
|
||||
<Input value={allSetting.timeLocation} onChange={(e) => updateSetting({ timeLocation: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.datepicker')} description={t('pages.settings.datepickerDescription')}>
|
||||
<Select
|
||||
value={allSetting.datepicker || 'gregorian'}
|
||||
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
|
||||
style={{ width: '100%' }}
|
||||
options={DATEPICKER_LIST.map((d) => ({ value: d.value, label: d.name }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '6',
|
||||
label: 'LDAP',
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title="Enable LDAP sync">
|
||||
<Switch checked={allSetting.ldapEnable} onChange={(v) => updateSetting({ ldapEnable: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="LDAP host">
|
||||
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="LDAP port">
|
||||
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Use TLS (LDAPS)">
|
||||
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Bind DN">
|
||||
<Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('password')}
|
||||
description={allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.'}
|
||||
>
|
||||
<Input.Password
|
||||
value={allSetting.ldapPassword}
|
||||
placeholder={allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''}
|
||||
onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Base DN">
|
||||
<Input value={allSetting.ldapBaseDN} onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="User filter">
|
||||
<Input value={allSetting.ldapUserFilter} onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="User attribute (username/email)">
|
||||
<Input value={allSetting.ldapUserAttr} onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="VLESS flag attribute">
|
||||
<Input value={allSetting.ldapVlessField} onChange={(e) => updateSetting({ ldapVlessField: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Generic flag attribute (optional)" description="If set, overrides VLESS flag — e.g. shadowInactive.">
|
||||
<Input value={allSetting.ldapFlagField} onChange={(e) => updateSetting({ ldapFlagField: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Truthy values" description="Comma-separated; default: true,1,yes,on">
|
||||
<Input value={allSetting.ldapTruthyValues} onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Invert flag" description="Enable when the attribute means disabled (e.g. shadowInactive).">
|
||||
<Switch checked={allSetting.ldapInvertFlag} onChange={(v) => updateSetting({ ldapInvertFlag: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Sync schedule" description="Cron-like string, e.g. @every 1m">
|
||||
<Input value={allSetting.ldapSyncCron} onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Inbound tags" description="Inbounds that LDAP sync may auto-create or auto-delete clients on.">
|
||||
<>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={ldapInboundTagList}
|
||||
onChange={setLdapInboundTagList}
|
||||
style={{ width: '100%' }}
|
||||
options={inboundOptions}
|
||||
/>
|
||||
{inboundOptions.length === 0 && (
|
||||
<div className="ldap-no-inbounds">No inbounds found. Create one in Inbounds first.</div>
|
||||
)}
|
||||
</>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Auto create clients">
|
||||
<Switch checked={allSetting.ldapAutoCreate} onChange={(v) => updateSetting({ ldapAutoCreate: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Auto delete clients">
|
||||
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Default total (GB)">
|
||||
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Default expiry (days)">
|
||||
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title="Default IP limit">
|
||||
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import SettingListItem from '@/components/SettingListItem.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
// Reactive AllSetting instance shared with the parent page.
|
||||
allSetting: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Remark model — legacy stores it as a single string where index 0 is
|
||||
// the separator char and the rest is the order of model keys
|
||||
// (i=Inbound, e=Email, o=Other). Surface it as two v-models that read
|
||||
// and write the underlying string.
|
||||
const remarkModels = { i: 'Inbound', e: 'Email', o: 'Other' };
|
||||
const remarkSeparators = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
|
||||
|
||||
const remarkModel = computed({
|
||||
get: () => {
|
||||
const rm = props.allSetting.remarkModel || '';
|
||||
return rm.length > 1 ? rm.substring(1).split('') : [];
|
||||
},
|
||||
set: (value) => {
|
||||
const sep = (props.allSetting.remarkModel || '-').charAt(0);
|
||||
props.allSetting.remarkModel = sep + value.join('');
|
||||
},
|
||||
});
|
||||
|
||||
const remarkSeparator = computed({
|
||||
get: () => {
|
||||
const rm = props.allSetting.remarkModel || '-';
|
||||
return rm.length > 1 ? rm.charAt(0) : '-';
|
||||
},
|
||||
set: (value) => {
|
||||
const tail = (props.allSetting.remarkModel || '-').substring(1);
|
||||
props.allSetting.remarkModel = value + tail;
|
||||
},
|
||||
});
|
||||
|
||||
const remarkSample = computed(() => {
|
||||
const parts = remarkModel.value.map((k) => remarkModels[k]);
|
||||
return parts.length === 0 ? '' : parts.join(remarkSeparator.value);
|
||||
});
|
||||
|
||||
const datepicker = computed({
|
||||
get: () => props.allSetting.datepicker || 'gregorian',
|
||||
set: (value) => { props.allSetting.datepicker = value; },
|
||||
});
|
||||
|
||||
const datepickerList = [
|
||||
{ name: 'Gregorian (Standard)', value: 'gregorian' },
|
||||
{ name: 'Jalalian (شمسی)', value: 'jalalian' },
|
||||
];
|
||||
|
||||
// Language is stored client-side in a cookie, NOT in AllSetting. The
|
||||
// legacy panel reloads on change so the Go side renders templates in
|
||||
// the new language.
|
||||
const lang = ref(LanguageManager.getLanguage());
|
||||
function onLangChange() {
|
||||
LanguageManager.setLanguage(lang.value);
|
||||
}
|
||||
|
||||
// LDAP inbound tags are CSV on the wire; expose as an array so the
|
||||
// multi-select v-model works directly.
|
||||
const ldapInboundTagList = computed({
|
||||
get: () => {
|
||||
const csv = props.allSetting.ldapInboundTags || '';
|
||||
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
},
|
||||
set: (list) => {
|
||||
props.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : '';
|
||||
},
|
||||
});
|
||||
|
||||
const inboundOptions = ref([]);
|
||||
async function loadInboundTags() {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
inboundOptions.value = msg.obj.map((ib) => ({
|
||||
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
|
||||
value: ib.tag,
|
||||
}));
|
||||
} else {
|
||||
inboundOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadInboundTags);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.remarkModel') }}</template>
|
||||
<template #description>{{ t('pages.settings.sampleRemark') }}: <i>#{{ remarkSample }}</i></template>
|
||||
<template #control>
|
||||
<a-input-group :style="{ width: '100%' }">
|
||||
<a-select v-model:value="remarkModel" mode="multiple"
|
||||
:style="{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }">
|
||||
<a-select-option v-for="(label, key) in remarkModels" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="remarkSeparator" :style="{ width: '20%' }">
|
||||
<a-select-option v-for="sep in remarkSeparators" :key="sep" :value="sep">{{ sep }}</a-select-option>
|
||||
</a-select>
|
||||
</a-input-group>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelListeningIP') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelListeningIPDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webListen" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelListeningDomain') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelListeningDomainDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webDomain" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelPort') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelPortDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.webPort" :min="1" :max="65535" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelUrlPath') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelUrlPathDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webBasePath" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.sessionMaxAge') }}</template>
|
||||
<template #description>{{ t('pages.settings.sessionMaxAgeDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.sessionMaxAge" :min="60" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Trusted proxy CIDRs</template>
|
||||
<template #description>Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.trustedProxyCIDRs" placeholder="127.0.0.1/32,::1/128" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
||||
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.pageSize" :min="0" :step="5" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.language') }}</template>
|
||||
<template #control>
|
||||
<a-select v-model:value="lang" :style="{ width: '100%' }" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
|
||||
:label="l.value">
|
||||
<span role="img" :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" :header="t('pages.settings.notifications')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.expireTimeDiff') }}</template>
|
||||
<template #description>{{ t('pages.settings.expireTimeDiffDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.expireDiff" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.trafficDiff') }}</template>
|
||||
<template #description>{{ t('pages.settings.trafficDiffDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.trafficDiff" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.settings.certs')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.publicKeyPath') }}</template>
|
||||
<template #description>{{ t('pages.settings.publicKeyPathDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webCertFile" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.privateKeyPath') }}</template>
|
||||
<template #description>{{ t('pages.settings.privateKeyPathDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webKeyFile" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="4" :header="t('pages.settings.externalTraffic')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.externalTrafficInformEnable') }}</template>
|
||||
<template #description>{{ t('pages.settings.externalTrafficInformEnableDesc') }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.externalTrafficInformEnable" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.externalTrafficInformURI') }}</template>
|
||||
<template #description>{{ t('pages.settings.externalTrafficInformURIDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.externalTrafficInformURI" placeholder="(http|https)://domain[:port]/path/"
|
||||
type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.restartXrayOnClientDisable') }}</template>
|
||||
<template #description>{{ t('pages.settings.restartXrayOnClientDisableDesc') }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.restartXrayOnClientDisable" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="5" :header="t('pages.settings.dateAndTime')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.timeZone') }}</template>
|
||||
<template #description>{{ t('pages.settings.timeZoneDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.timeLocation" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.datepicker') }}</template>
|
||||
<template #description>{{ t('pages.settings.datepickerDescription') }}</template>
|
||||
<template #control>
|
||||
<a-select v-model:value="datepicker" :style="{ width: '100%' }">
|
||||
<a-select-option v-for="item in datepickerList" :key="item.value" :value="item.value">
|
||||
{{ item.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="6" header="LDAP">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Enable LDAP sync</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapEnable" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>LDAP host</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapHost" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>LDAP port</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapPort" :min="1" :max="65535" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Use TLS (LDAPS)</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapUseTLS" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Bind DN</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapBindDN" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('password') }}</template>
|
||||
<template #description>
|
||||
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
|
||||
</template>
|
||||
<template #control>
|
||||
<a-input-password v-model:value="allSetting.ldapPassword"
|
||||
:placeholder="allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Base DN</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapBaseDN" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>User filter</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapUserFilter" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>User attribute (username/email)</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapUserAttr" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>VLESS flag attribute</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapVlessField" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Generic flag attribute (optional)</template>
|
||||
<template #description>If set, overrides VLESS flag — e.g. shadowInactive.</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapFlagField" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Truthy values</template>
|
||||
<template #description>Comma-separated; default: true,1,yes,on</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapTruthyValues" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Invert flag</template>
|
||||
<template #description>Enable when the attribute means disabled (e.g. shadowInactive).</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapInvertFlag" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Sync schedule</template>
|
||||
<template #description>Cron-like string, e.g. @every 1m</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapSyncCron" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Inbound tags</template>
|
||||
<template #description>Inbounds that LDAP sync may auto-create or auto-delete clients on.</template>
|
||||
<template #control>
|
||||
<a-select v-model:value="ldapInboundTagList" mode="multiple" :style="{ width: '100%' }">
|
||||
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div v-if="inboundOptions.length === 0" class="ldap-no-inbounds">
|
||||
No inbounds found. Create one in Inbounds first.
|
||||
</div>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Auto create clients</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapAutoCreate" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Auto delete clients</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapAutoDelete" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Default total (GB)</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapDefaultTotalGB" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Default expiry (days)</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapDefaultExpiryDays" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Default IP limit</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapDefaultLimitIP" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ldap-no-inbounds {
|
||||
margin-top: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
.api-token-section {
|
||||
padding: 8px 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-token-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
opacity: 0.7;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.api-token-row {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.api-token-row.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.api-token-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-name-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.api-token-name {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.api-token-created {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.api-token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-token-value-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.security-actions {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import SettingListItem from '@/components/SettingListItem';
|
||||
import TwoFactorModal from './TwoFactorModal';
|
||||
import './SecurityTab.css';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface ApiTokenRow {
|
||||
id: number;
|
||||
name: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface SecurityTabProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
type TfaType = 'set' | 'confirm';
|
||||
|
||||
interface TfaState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
token: string;
|
||||
type: TfaType;
|
||||
onConfirm: (success: boolean, code?: string) => void;
|
||||
}
|
||||
|
||||
const TFA_INITIAL: TfaState = {
|
||||
open: false,
|
||||
title: '',
|
||||
description: '',
|
||||
token: '',
|
||||
type: 'set',
|
||||
onConfirm: () => {},
|
||||
};
|
||||
|
||||
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
|
||||
const [user, setUser] = useState({
|
||||
oldUsername: '',
|
||||
oldPassword: '',
|
||||
newUsername: '',
|
||||
newPassword: '',
|
||||
});
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
|
||||
const [apiTokensLoading, setApiTokensLoading] = useState(false);
|
||||
const [visibleTokenIds, setVisibleTokenIds] = useState<Set<number>>(() => new Set());
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
|
||||
setTfa({ ...opts, open: true });
|
||||
}, []);
|
||||
|
||||
const onTfaConfirm = useCallback((success: boolean, code?: string) => {
|
||||
tfa.onConfirm(success, code);
|
||||
}, [tfa]);
|
||||
|
||||
function updateUserField<K extends keyof typeof user>(key: K, value: string) {
|
||||
setUser((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const sendUpdateUser = useCallback(async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg;
|
||||
if (msg?.success) {
|
||||
await HttpUtil.post('/logout');
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
function onUpdateUserClick() {
|
||||
if (allSetting.twoFactorEnable) {
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
|
||||
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
|
||||
token: allSetting.twoFactorToken,
|
||||
type: 'confirm',
|
||||
onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); },
|
||||
});
|
||||
} else {
|
||||
sendUpdateUser();
|
||||
}
|
||||
}
|
||||
|
||||
const loadApiTokens = useCallback(async () => {
|
||||
setApiTokensLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
|
||||
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
} finally {
|
||||
setApiTokensLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
loadApiTokens();
|
||||
}, [loadApiTokens]);
|
||||
|
||||
function toggleTokenVisibility(id: number) {
|
||||
setVisibleTokenIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function copyToken(token: string) {
|
||||
if (!token) return;
|
||||
const ok = await ClipboardManager.copyText(token);
|
||||
if (ok) messageApi.success(t('copySuccess'));
|
||||
else messageApi.error(t('copyFail') ?? 'Copy failed');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
setCreateName('');
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
async function confirmCreateToken() {
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
messageApi.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ id?: number }>;
|
||||
if (msg?.success) {
|
||||
setCreateOpen(false);
|
||||
await loadApiTokens();
|
||||
if (msg.obj?.id != null) {
|
||||
const id = msg.obj.id;
|
||||
setVisibleTokenIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteToken(row: ApiTokenRow) {
|
||||
modal.confirm({
|
||||
title: `${t('delete')} "${row.name}"?`,
|
||||
content: t('pages.settings.security.apiTokenDeleteWarning')
|
||||
|| 'Any caller using this token will stop authenticating immediately.',
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg;
|
||||
if (msg?.success) await loadApiTokens();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleTokenEnabled(row: ApiTokenRow) {
|
||||
const target = !row.enabled;
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
|
||||
if (msg?.success) {
|
||||
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
|
||||
}
|
||||
}
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (!token) return '';
|
||||
return '•'.repeat(Math.min(token.length, 24));
|
||||
}
|
||||
|
||||
function formatTokenDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function toggleTwoFactor() {
|
||||
if (!allSetting.twoFactorEnable) {
|
||||
const newToken = RandomUtil.randomBase32String();
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalSetTitle'),
|
||||
description: '',
|
||||
token: newToken,
|
||||
type: 'set',
|
||||
onConfirm: (ok: boolean) => {
|
||||
if (ok) {
|
||||
messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
|
||||
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
|
||||
} else {
|
||||
updateSetting({ twoFactorEnable: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
|
||||
description: t('pages.settings.security.twoFactorModalRemoveStep'),
|
||||
token: allSetting.twoFactorToken,
|
||||
type: 'confirm',
|
||||
onConfirm: (ok: boolean) => {
|
||||
if (!ok) return;
|
||||
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
||||
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Collapse defaultActiveKey="1" items={[
|
||||
{
|
||||
key: '1',
|
||||
label: t('pages.settings.security.admin'),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
|
||||
<Input value={user.oldUsername} autoComplete="username"
|
||||
onChange={(e) => updateUserField('oldUsername', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.currentPassword')}>
|
||||
<Input.Password value={user.oldPassword} autoComplete="current-password"
|
||||
onChange={(e) => updateUserField('oldPassword', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.newUsername')}>
|
||||
<Input value={user.newUsername}
|
||||
onChange={(e) => updateUserField('newUsername', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.newPassword')}>
|
||||
<Input.Password value={user.newPassword} autoComplete="new-password"
|
||||
onChange={(e) => updateUserField('newPassword', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<div className="security-actions">
|
||||
<Space style={{ padding: '0 20px' }}>
|
||||
<Button type="primary" loading={updating} onClick={onUpdateUserClick}>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('pages.settings.security.twoFactor'),
|
||||
children: (
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.security.twoFactorEnable')}
|
||||
description={t('pages.settings.security.twoFactorEnableDesc')}
|
||||
>
|
||||
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
|
||||
</SettingListItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('pages.nodes.apiToken'),
|
||||
children: (
|
||||
<div className="api-token-section">
|
||||
<div className="api-token-header">
|
||||
<p className="api-token-hint">{t('pages.nodes.apiTokenHint')}</p>
|
||||
<Button type="primary" size="small" onClick={openCreateModal}>
|
||||
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
|
||||
</Button>
|
||||
</div>
|
||||
<Spin spinning={apiTokensLoading}>
|
||||
{!apiTokens.length && !apiTokensLoading && (
|
||||
<Empty description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'} />
|
||||
)}
|
||||
{apiTokens.map((row) => (
|
||||
<div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
|
||||
<div className="api-token-row-head">
|
||||
<div className="api-token-name-wrap">
|
||||
<span className="api-token-name">{row.name}</span>
|
||||
<span className="api-token-created">{formatTokenDate(row.createdAt)}</span>
|
||||
</div>
|
||||
<div className="api-token-actions">
|
||||
<Switch size="small" checked={row.enabled} onChange={() => toggleTokenEnabled(row)} />
|
||||
<Button size="small" danger type="text" onClick={() => confirmDeleteToken(row)}>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="api-token-value-wrap">
|
||||
<code className="api-token-value">
|
||||
{visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)}
|
||||
</code>
|
||||
<Button size="small" onClick={() => toggleTokenVisibility(row.id)}>
|
||||
{visibleTokenIds.has(row.id)
|
||||
? (t('pages.settings.security.hide') || 'Hide')
|
||||
: (t('pages.settings.security.show') || 'Show')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => copyToken(row.token)}>{t('copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Spin>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={t('pages.settings.security.apiTokenNew') || 'New API token'}
|
||||
confirmLoading={creating}
|
||||
okText={t('confirm')}
|
||||
cancelText={t('cancel')}
|
||||
onOk={confirmCreateToken}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('pages.settings.security.apiTokenName') || 'Name'} required>
|
||||
<Input
|
||||
value={createName}
|
||||
maxLength={64}
|
||||
placeholder={t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onPressEnter={confirmCreateToken}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<TwoFactorModal
|
||||
open={tfa.open}
|
||||
title={tfa.title}
|
||||
description={tfa.description}
|
||||
token={tfa.token}
|
||||
type={tfa.type}
|
||||
onConfirm={onTfaConfirm}
|
||||
onOpenChange={(open) => setTfa((prev) => ({ ...prev, open }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import SettingListItem from '@/components/SettingListItem.vue';
|
||||
import TwoFactorModal from './TwoFactorModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
allSetting: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// 2FA modal state — both the "set" (enabling) and "confirm" (changing
|
||||
// password / disabling) flows route through the same component.
|
||||
const tfa = reactive({
|
||||
open: false,
|
||||
title: '',
|
||||
description: '',
|
||||
token: '',
|
||||
type: 'set',
|
||||
// resolveConfirm is called by the modal's @confirm with the success bool;
|
||||
// it then routes the value back to whichever flow opened the modal.
|
||||
resolveConfirm: (_success) => { },
|
||||
});
|
||||
|
||||
function openTfa({ title, description = '', token = '', type, onConfirm }) {
|
||||
tfa.title = title;
|
||||
tfa.description = description;
|
||||
tfa.token = token;
|
||||
tfa.type = type;
|
||||
tfa.resolveConfirm = onConfirm;
|
||||
tfa.open = true;
|
||||
}
|
||||
|
||||
function onTfaConfirm(success) {
|
||||
tfa.resolveConfirm(success);
|
||||
}
|
||||
|
||||
const user = reactive({
|
||||
oldUsername: '',
|
||||
oldPassword: '',
|
||||
newUsername: '',
|
||||
newPassword: '',
|
||||
});
|
||||
const updating = ref(false);
|
||||
|
||||
async function sendUpdateUser() {
|
||||
updating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
|
||||
if (msg?.success) {
|
||||
await HttpUtil.post('/logout');
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUser() {
|
||||
if (props.allSetting.twoFactorEnable) {
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
|
||||
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
|
||||
token: props.allSetting.twoFactorToken,
|
||||
type: 'confirm',
|
||||
onConfirm: (ok) => { if (ok) sendUpdateUser(); },
|
||||
});
|
||||
} else {
|
||||
sendUpdateUser();
|
||||
}
|
||||
}
|
||||
|
||||
const apiTokens = ref([]);
|
||||
const apiTokensLoading = ref(false);
|
||||
const visibleTokenIds = ref(new Set());
|
||||
const createOpen = ref(false);
|
||||
const createName = ref('');
|
||||
const creating = ref(false);
|
||||
|
||||
async function loadApiTokens() {
|
||||
apiTokensLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/apiTokens');
|
||||
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
apiTokensLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenVisible(id) {
|
||||
return visibleTokenIds.value.has(id);
|
||||
}
|
||||
|
||||
function toggleTokenVisibility(id) {
|
||||
const next = new Set(visibleTokenIds.value);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
visibleTokenIds.value = next;
|
||||
}
|
||||
|
||||
async function copyToken(token) {
|
||||
if (!token) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
message.success(t('copySuccess'));
|
||||
} catch (_e) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = token;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
message.success(t('copySuccess'));
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
createName.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmCreateToken() {
|
||||
const name = createName.value.trim();
|
||||
if (!name) {
|
||||
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
|
||||
if (msg?.success) {
|
||||
createOpen.value = false;
|
||||
await loadApiTokens();
|
||||
if (msg.obj?.id != null) {
|
||||
const next = new Set(visibleTokenIds.value);
|
||||
next.add(msg.obj.id);
|
||||
visibleTokenIds.value = next;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteToken(row) {
|
||||
Modal.confirm({
|
||||
title: `${t('delete')} "${row.name}"?`,
|
||||
content: t('pages.settings.security.apiTokenDeleteWarning')
|
||||
|| 'Any caller using this token will stop authenticating immediately.',
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
|
||||
if (msg?.success) await loadApiTokens();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleTokenEnabled(row) {
|
||||
const target = !row.enabled;
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
|
||||
if (msg?.success) row.enabled = target;
|
||||
}
|
||||
|
||||
function maskToken(token) {
|
||||
if (!token) return '';
|
||||
return '•'.repeat(Math.min(token.length, 24));
|
||||
}
|
||||
|
||||
function formatTokenDate(ts) {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
onMounted(loadApiTokens);
|
||||
|
||||
function toggleTwoFactor() {
|
||||
// Switch read-only — the actual flip happens after the modal succeeds.
|
||||
if (!props.allSetting.twoFactorEnable) {
|
||||
const newToken = RandomUtil.randomBase32String();
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalSetTitle'),
|
||||
token: newToken,
|
||||
type: 'set',
|
||||
onConfirm: (ok) => {
|
||||
if (ok) {
|
||||
message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
|
||||
props.allSetting.twoFactorToken = newToken;
|
||||
}
|
||||
props.allSetting.twoFactorEnable = ok;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
|
||||
description: t('pages.settings.security.twoFactorModalRemoveStep'),
|
||||
token: props.allSetting.twoFactorToken,
|
||||
type: 'confirm',
|
||||
onConfirm: (ok) => {
|
||||
if (!ok) return;
|
||||
message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
||||
props.allSetting.twoFactorEnable = false;
|
||||
props.allSetting.twoFactorToken = '';
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse-panel key="1" :header="t('pages.settings.security.admin')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.oldUsername') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="user.oldUsername" autocomplete="username" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.currentPassword') }}</template>
|
||||
<template #control>
|
||||
<a-input-password v-model:value="user.oldPassword" autocomplete="current-password" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.newUsername') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="user.newUsername" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.newPassword') }}</template>
|
||||
<template #control>
|
||||
<a-input-password v-model:value="user.newPassword" autocomplete="new-password" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<a-list-item>
|
||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
||||
<a-button type="primary" :loading="updating" @click="updateUser">{{ t('confirm') }}</a-button>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" :header="t('pages.settings.security.twoFactor')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.security.twoFactorEnable') }}</template>
|
||||
<template #description>{{ t('pages.settings.security.twoFactorEnableDesc') }}</template>
|
||||
<template #control>
|
||||
<a-switch :checked="allSetting.twoFactorEnable" @click="toggleTwoFactor" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
|
||||
<div class="api-token-section">
|
||||
<div class="api-token-header">
|
||||
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
|
||||
<a-button type="primary" size="small" @click="openCreateModal">
|
||||
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="apiTokensLoading">
|
||||
<a-empty v-if="!apiTokens.length && !apiTokensLoading"
|
||||
:description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
|
||||
|
||||
<div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
|
||||
<div class="api-token-row-head">
|
||||
<div class="api-token-name-wrap">
|
||||
<span class="api-token-name">{{ row.name }}</span>
|
||||
<span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="api-token-actions">
|
||||
<a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
|
||||
<a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
|
||||
{{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-token-value-wrap">
|
||||
<code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
|
||||
<a-button size="small" @click="toggleTokenVisibility(row.id)">
|
||||
{{ isTokenVisible(row.id)
|
||||
? (t('pages.settings.security.hide') || 'Hide')
|
||||
: (t('pages.settings.security.show') || 'Show') }}
|
||||
</a-button>
|
||||
<a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
<a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
|
||||
:confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
|
||||
<a-input v-model:value="createName" maxlength="64"
|
||||
:placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
|
||||
@keyup.enter="confirmCreateToken" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
|
||||
:type="tfa.type" @confirm="onTfaConfirm" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-token-section {
|
||||
padding: 8px 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-token-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
opacity: 0.7;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.api-token-row {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.api-token-row.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.api-token-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-name-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.api-token-name {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.api-token-created {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.api-token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-token-value-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user