mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-18 01:00:59 +00:00
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-donate {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-donate:hover,
|
||||
.sidebar-donate:focus-visible {
|
||||
background-color: rgba(236, 72, 153, 0.12);
|
||||
color: #ec4899;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-donate .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle:hover,
|
||||
.sidebar-theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClusterOutlined,
|
||||
CloseOutlined,
|
||||
DashboardOutlined,
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
|
||||
interface AppSidebarProps {
|
||||
basePath?: string;
|
||||
requestUri?: string;
|
||||
}
|
||||
|
||||
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
team: TeamOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-donate"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
id: string;
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
onCycle: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="sidebar-theme-cycle"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
onClick={onCycle}
|
||||
>
|
||||
{!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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
], [prefix, t]);
|
||||
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
|
||||
|
||||
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
return {
|
||||
key: tab.key,
|
||||
icon: <Icon />,
|
||||
label: tab.title,
|
||||
};
|
||||
}),
|
||||
[]);
|
||||
|
||||
const openLink = useCallback(async (key: string) => {
|
||||
if (key === 'logout') {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = basePath || '/';
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}, [basePath]);
|
||||
|
||||
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
|
||||
openLink(String(key));
|
||||
}, [openLink]);
|
||||
|
||||
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
|
||||
setCollapsed(isCollapsed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback((id: string) => {
|
||||
pauseAnimationsUntilLeave(id);
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
return (
|
||||
<div className="ant-sidebar">
|
||||
<Layout.Sider
|
||||
theme={currentTheme}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
breakpoint="md"
|
||||
onCollapse={onSiderCollapse}
|
||||
>
|
||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
{!collapsed && panelVersion && (
|
||||
<span className="brand-version">v{panelVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="brand-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="sider-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="sider-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={false}
|
||||
open={drawerOpen}
|
||||
rootClassName={currentTheme}
|
||||
size="min(82vw, 320px)"
|
||||
styles={{
|
||||
wrapper: { padding: 0 },
|
||||
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle-drawer')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
<button
|
||||
className="drawer-close"
|
||||
type="button"
|
||||
aria-label={t('close')}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="drawer-menu drawer-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="drawer-menu drawer-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{!drawerOpen && (
|
||||
<button
|
||||
className="drawer-handle"
|
||||
type="button"
|
||||
aria-label={t('menu.dashboard')}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
ClusterOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
MenuOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
|
||||
const props = defineProps({
|
||||
basePath: { type: String, default: '' },
|
||||
// Current request URI so the matching menu item highlights.
|
||||
requestUri: { type: String, default: '' },
|
||||
});
|
||||
|
||||
|
||||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
team: TeamOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
]);
|
||||
|
||||
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
|
||||
const activeTab = ref([props.requestUri]);
|
||||
const drawerOpen = ref(false);
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
const drawerWidth = 'min(82vw, 320px)';
|
||||
|
||||
async function openLink(key) {
|
||||
if (key === 'logout') {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = props.basePath || '/';
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapse(isCollapsed, type) {
|
||||
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
|
||||
collapsed.value = isCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
drawerOpen.value = !drawerOpen.value;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('theme-cycle');
|
||||
if (!theme.isDark) {
|
||||
toggleTheme();
|
||||
if (theme.isUltra) toggleUltra();
|
||||
} else if (!theme.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ant-sidebar">
|
||||
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
|
||||
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.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="!theme.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>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||
:wrap-style="{ padding: 0 }" :width="drawerWidth"
|
||||
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||
:header-style="{ display: 'none' }" @close="closeDrawer">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-brand">3X-UI</span>
|
||||
<div class="drawer-header-actions">
|
||||
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.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="!theme.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>
|
||||
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
|
||||
@click="toggleDrawer">
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed sider only has room for the '3X' brand — center it and
|
||||
* hide the theme cycle button (which is `v-if`-ed out in template). */
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 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.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item .anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
.ant-statistic-content {
|
||||
font-size: 17px !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-statistic-content-value,
|
||||
.ant-statistic-content-prefix,
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix {
|
||||
margin-inline-end: 8px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix .anticon {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 12px !important;
|
||||
opacity: 0.55;
|
||||
margin-inline-start: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 11px !important;
|
||||
margin-bottom: 6px !important;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Statistic } from 'antd';
|
||||
import './CustomStatistic.css';
|
||||
|
||||
interface CustomStatisticProps {
|
||||
title?: string;
|
||||
value?: string | number;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
}
|
||||
|
||||
export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
|
||||
return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-content) {
|
||||
color: var(--dark-color-text-primary);
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-title) {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
.jdp-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jdp-wrap > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jdp-wrap input {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.jdp-dark .jdp-wrap input,
|
||||
.jdp-dark input {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
background-color: #23252b !important;
|
||||
}
|
||||
|
||||
.jdp-ultra .jdp-wrap input,
|
||||
.jdp-ultra input {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
background-color: #101013 !important;
|
||||
}
|
||||
|
||||
.jdp-dark input::placeholder,
|
||||
.jdp-ultra input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.30) !important;
|
||||
}
|
||||
|
||||
.jdp-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { PersianDateTimePicker } from 'persian-calendar-suite';
|
||||
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './DateTimePicker.css';
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: Dayjs | null;
|
||||
onChange: (next: Dayjs | null) => void;
|
||||
showTime?: boolean;
|
||||
format?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LIGHT_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#d9d9d9',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.10)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(0, 0, 0, 0.88)',
|
||||
};
|
||||
|
||||
const DARK_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#23252b',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.18)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
||||
};
|
||||
|
||||
const ULTRA_DARK_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#101013',
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.16)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
||||
};
|
||||
|
||||
export default function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
showTime = true,
|
||||
format = 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
}: DateTimePickerProps) {
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isDark, isUltra } = useTheme();
|
||||
|
||||
const persianTheme = useMemo(() => {
|
||||
if (isUltra) return ULTRA_DARK_THEME;
|
||||
if (isDark) return DARK_THEME;
|
||||
return LIGHT_THEME;
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
if (datepicker === 'jalalian') {
|
||||
return (
|
||||
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
|
||||
<PersianDateTimePicker
|
||||
value={value ? value.valueOf() : null}
|
||||
onChange={(next: number | string | null) => {
|
||||
if (next == null || next === '') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const ms = typeof next === 'number' ? next : Number(next);
|
||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
||||
}}
|
||||
showTime={showTime}
|
||||
outputFormat="timestamp"
|
||||
persianNumbers
|
||||
rtlCalendar
|
||||
theme={persianTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import PersianDatePicker from 'vue3-persian-datetime-picker';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
|
||||
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
|
||||
// when the panel's "Calendar Type" setting is `jalalian`.
|
||||
//
|
||||
// The v-model contract matches AD-Vue: the parent works with a dayjs
|
||||
// object (or null). For the persian picker we serialize to/from the
|
||||
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
|
||||
// know which renderer is active.
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [Object, null], default: null },
|
||||
showTime: { type: Boolean, default: true },
|
||||
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value']);
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
const isJalali = computed(() => datepicker.value === 'jalalian');
|
||||
|
||||
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
// Persian picker's display format — `j…` tokens come from moment-jalaali
|
||||
// and render Jalali year/month/day.
|
||||
const persianDisplayFormat = computed(() =>
|
||||
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
|
||||
);
|
||||
|
||||
// Persian picker stores the date as a Gregorian string in the format
|
||||
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
|
||||
// dayjs(...) round-trips cleanly.
|
||||
const stringValue = computed({
|
||||
get() {
|
||||
const v = props.value;
|
||||
if (!v) return '';
|
||||
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
|
||||
},
|
||||
set(next) {
|
||||
if (!next) {
|
||||
emit('update:value', null);
|
||||
return;
|
||||
}
|
||||
const parsed = dayjs(next, ISO_FORMAT);
|
||||
emit('update:value', parsed.isValid() ? parsed : null);
|
||||
},
|
||||
});
|
||||
|
||||
function onAntChange(next) {
|
||||
emit('update:value', next || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
|
||||
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
|
||||
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
|
||||
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
|
||||
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jalali-datepicker {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
|
||||
by default (its tokens live in JS), so we hardcode hexes per theme
|
||||
class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
|
||||
for the neutral ultra-dark variant. The popup stays inside the
|
||||
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
|
||||
<style>
|
||||
/* ===== Light (default) =================================================== */
|
||||
|
||||
.persian-datepicker-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 11px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Light theme keeps the picker's brand-blue calendar button (set via
|
||||
* inline style on .vpd-icon-btn) — only its border + corner radius are
|
||||
* normalized so it sits flush with the input. Dark/ultra-dark themes
|
||||
* below override the inline blue so the control matches the form. */
|
||||
.vpd-main .vpd-icon-btn {
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
/* Match the input's left edge (no rounded left, no double border at the
|
||||
* seam) so it sits flush against the icon-btn. */
|
||||
.persian-datepicker-input {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.vpd-main .vpd-clear-btn {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
|
||||
* fits flush. Don't add `border` here — box-sizing: border-box would
|
||||
* eat 2px from the content width and the 7th day-cell of each row
|
||||
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
|
||||
.vpd-wrapper .vpd-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header .vpd-year-label,
|
||||
.vpd-wrapper .vpd-header .vpd-date,
|
||||
.vpd-wrapper .vpd-header .vpd-locales li {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-month-label,
|
||||
.vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-week,
|
||||
.vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* The picker's <arrow> component renders an inline SVG with a hardcoded
|
||||
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
|
||||
* is visible in every theme. */
|
||||
.vpd-wrapper .vpd-next svg path,
|
||||
.vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #1677ff;
|
||||
}
|
||||
|
||||
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
|
||||
* is invisible on white and dark themes alike. Reset the day text color
|
||||
* across all states so days are always readable. */
|
||||
.vpd-wrapper .vpd-day,
|
||||
.vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.88) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day[disabled='true'],
|
||||
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
|
||||
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list,
|
||||
.vpd-wrapper .vpd-addon-list-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
.vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ===== Dark (navy) ======================================================= */
|
||||
|
||||
body.dark .persian-datepicker-input {
|
||||
background: #252526;
|
||||
border-color: #3c3c3c;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
body.dark .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #3c3c3c !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-content {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-week,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-next svg path,
|
||||
body.dark .vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day,
|
||||
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'],
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Ultra-dark (neutral black) ======================================= */
|
||||
|
||||
html[data-theme='ultra-dark'] .persian-datepicker-input {
|
||||
background: #0a0a0a;
|
||||
border-color: #303030;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #303030 !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
|
||||
background: #141414;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #141414;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,738 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/outbound.js';
|
||||
|
||||
interface StreamShape {
|
||||
network?: string;
|
||||
kcp?: { mtu?: number };
|
||||
finalmask: {
|
||||
tcp?: MaskRow[];
|
||||
udp?: MaskRow[];
|
||||
enableQuicParams?: boolean;
|
||||
quicParams?: QuicParams;
|
||||
};
|
||||
addTcpMask: (type?: string) => void;
|
||||
delTcpMask: (index: number) => void;
|
||||
addUdpMask: (type?: string) => void;
|
||||
delUdpMask: (index: number) => void;
|
||||
}
|
||||
|
||||
interface MaskRow {
|
||||
type: string;
|
||||
settings: Record<string, unknown>;
|
||||
_getDefaultSettings: (type: string, settings: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ItemRow {
|
||||
type: string;
|
||||
packet: string | unknown[];
|
||||
delay?: number | string;
|
||||
rand?: number | string;
|
||||
randRange?: string;
|
||||
}
|
||||
|
||||
interface QuicParams {
|
||||
congestion: string;
|
||||
debug?: boolean;
|
||||
brutalUp?: number | string;
|
||||
brutalDown?: number | string;
|
||||
hasUdpHop?: boolean;
|
||||
udpHop?: { ports: string; interval: string | number };
|
||||
maxIdleTimeout?: number;
|
||||
keepAlivePeriod?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
maxIncomingStreams?: number;
|
||||
initStreamReceiveWindow?: number;
|
||||
maxStreamReceiveWindow?: number;
|
||||
initConnectionReceiveWindow?: number;
|
||||
maxConnectionReceiveWindow?: number;
|
||||
}
|
||||
|
||||
interface FinalMaskFormProps {
|
||||
stream: StreamShape;
|
||||
protocol: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
function changeMaskType(mask: MaskRow, type: string) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
function changeItemType(item: ItemRow, type: string) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') {
|
||||
item.rand = 0;
|
||||
item.packet = [];
|
||||
} else item.packet = '';
|
||||
}
|
||||
|
||||
function newClientServerItem(): ItemRow {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem(): ItemRow {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem(): ItemRow {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) {
|
||||
const isHysteria = protocol === Protocols.Hysteria || protocol === 'hysteria';
|
||||
const network = stream?.network || '';
|
||||
|
||||
const showTcp = useMemo(
|
||||
() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network),
|
||||
[network],
|
||||
);
|
||||
const showUdp = isHysteria || network === 'kcp';
|
||||
const showQuic = isHysteria || network === 'xhttp';
|
||||
|
||||
function notify() {
|
||||
onChange();
|
||||
}
|
||||
|
||||
function changeUdpMaskType(mask: MaskRow, type: string) {
|
||||
changeMaskType(mask, type);
|
||||
if (network === 'kcp' && stream.kcp) {
|
||||
stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
|
||||
stream.addUdpMask(def);
|
||||
notify();
|
||||
}
|
||||
|
||||
const tcpMasks = stream.finalmask.tcp || [];
|
||||
const udpMasks = stream.finalmask.udp || [];
|
||||
|
||||
if (!showTcp && !showUdp && !showQuic) return null;
|
||||
|
||||
return (
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
{showTcp && (
|
||||
<>
|
||||
<Form.Item label="TCP Masks">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
stream.addTcpMask('fragment');
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{tcpMasks.map((mask, mIdx) => (
|
||||
<div key={`tcp-${mIdx}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
TCP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
stream.delTcpMask(mIdx);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={mask.type}
|
||||
onChange={(v) => {
|
||||
changeMaskType(mask, v);
|
||||
notify();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'fragment', label: 'Fragment' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'sudoku', label: 'Sudoku' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{mask.type === 'fragment' && (
|
||||
<>
|
||||
<Form.Item label="Packets">
|
||||
<Select
|
||||
value={mask.settings.packets as string}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).packets = v;
|
||||
notify();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'tlshello', label: 'tlshello' },
|
||||
{ value: '1-3', label: '1-3' },
|
||||
{ value: '1-5', label: '1-5' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(['length', 'delay', 'maxSplit'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field.charAt(0).toUpperCase() + field.slice(1)}>
|
||||
<Input
|
||||
value={(mask.settings[field] as string) || ''}
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mask.type === 'sudoku' && (
|
||||
<>
|
||||
{(['password', 'ascii', 'customTable', 'customTables'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'customTable' ? 'Custom Table' : field === 'customTables' ? 'Custom Tables' : field.charAt(0).toUpperCase() + field.slice(1)}>
|
||||
<Input
|
||||
value={(mask.settings[field] as string) || ''}
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
{(['paddingMin', 'paddingMax'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'paddingMin' ? 'Padding Min' : 'Padding Max'}>
|
||||
<InputNumber
|
||||
value={(mask.settings[field] as number) || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = Number(v) || 0;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mask.type === 'header-custom' && (
|
||||
<HeaderCustomGroups mask={mask} kind="tcp" onChange={notify} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showUdp && (
|
||||
<>
|
||||
<Form.Item label="UDP Masks">
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={addUdpMaskWithDefault} />
|
||||
</Form.Item>
|
||||
|
||||
{udpMasks.map((mask, mIdx) => (
|
||||
<div key={`udp-${mIdx}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
UDP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
stream.delUdpMask(mIdx);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={mask.type}
|
||||
onChange={(v) => changeUdpMaskType(mask, v)}
|
||||
options={
|
||||
isHysteria
|
||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||
: [
|
||||
{ value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
|
||||
{ value: 'header-dns', label: 'Header DNS' },
|
||||
{ value: 'header-dtls', label: 'Header DTLS 1.2' },
|
||||
{ value: 'header-srtp', label: 'Header SRTP' },
|
||||
{ value: 'header-utp', label: 'Header uTP' },
|
||||
{ value: 'header-wechat', label: 'Header WeChat Video' },
|
||||
{ value: 'header-wireguard', label: 'Header WireGuard' },
|
||||
{ value: 'mkcp-original', label: 'mKCP Original' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{['mkcp-aes128gcm', 'salamander'].includes(mask.type) && (
|
||||
<Form.Item label="Password">
|
||||
<Input
|
||||
value={(mask.settings.password as string) || ''}
|
||||
placeholder="Obfuscation password"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).password = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'header-dns' && (
|
||||
<Form.Item label="Domain">
|
||||
<Input
|
||||
value={(mask.settings.domain as string) || ''}
|
||||
placeholder="e.g., www.example.com"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).domain = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'xdns' && (
|
||||
<Form.Item label="Domains">
|
||||
<Select
|
||||
mode="tags"
|
||||
value={(mask.settings.domains as string[]) || []}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="e.g., www.example.com"
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).domains = v;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'noise' && (
|
||||
<NoiseItems mask={mask} onChange={notify} />
|
||||
)}
|
||||
|
||||
{mask.type === 'header-custom' && (
|
||||
<UdpHeaderCustom mask={mask} onChange={notify} />
|
||||
)}
|
||||
|
||||
{mask.type === 'xicmp' && (
|
||||
<>
|
||||
<Form.Item label="IP">
|
||||
<Input
|
||||
value={(mask.settings.ip as string) || ''}
|
||||
placeholder="0.0.0.0"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).ip = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="ID">
|
||||
<InputNumber
|
||||
value={(mask.settings.id as number) || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).id = Number(v) || 0;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showQuic && (
|
||||
<>
|
||||
<Form.Item label="QUIC Params">
|
||||
<Switch
|
||||
checked={!!stream.finalmask.enableQuicParams}
|
||||
onChange={(v) => {
|
||||
stream.finalmask.enableQuicParams = v;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{stream.finalmask.enableQuicParams && stream.finalmask.quicParams && (
|
||||
<QuicParamsForm params={stream.finalmask.quicParams} onChange={notify} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCustomGroups({
|
||||
mask,
|
||||
kind: _kind,
|
||||
onChange,
|
||||
}: {
|
||||
mask: MaskRow;
|
||||
kind: 'tcp';
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const settings = mask.settings as { clients?: ItemRow[][]; servers?: ItemRow[][] };
|
||||
if (!settings.clients) settings.clients = [];
|
||||
if (!settings.servers) settings.servers = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(['clients', 'servers'] as const).map((groupKey) => (
|
||||
<div key={groupKey}>
|
||||
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).push([newClientServerItem()]);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings[groupKey] as ItemRow[][]).map((group, gi) => (
|
||||
<div key={`${groupKey}-${gi}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
{group.map((item, _ii) => (
|
||||
<ItemEditor key={_ii} item={item} onChange={onChange} delayAsNumber />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
|
||||
const settings = mask.settings as { client?: ItemRow[]; server?: ItemRow[] };
|
||||
if (!settings.client) settings.client = [];
|
||||
if (!settings.server) settings.server = [];
|
||||
return (
|
||||
<>
|
||||
{(['client', 'server'] as const).map((groupKey) => (
|
||||
<div key={groupKey}>
|
||||
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).push(newUdpClientServerItem());
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings[groupKey] as ItemRow[]).map((item, ci) => (
|
||||
<div key={ci}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).splice(ci, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor item={item} onChange={onChange} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
|
||||
const settings = mask.settings as { reset?: number; noise?: ItemRow[] };
|
||||
if (!settings.noise) settings.noise = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Reset">
|
||||
<InputNumber
|
||||
value={settings.reset || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
settings.reset = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Noise">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).push(newNoiseItem());
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings.noise as ItemRow[]).map((n, ni) => (
|
||||
<div key={ni}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
Noise {ni + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).splice(ni, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor item={n} onChange={onChange} delayAsString />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemEditor({
|
||||
item,
|
||||
onChange,
|
||||
delayAsNumber,
|
||||
delayAsString,
|
||||
}: {
|
||||
item: ItemRow;
|
||||
onChange: () => void;
|
||||
delayAsNumber?: boolean;
|
||||
delayAsString?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={item.type}
|
||||
onChange={(v) => {
|
||||
changeItemType(item, v);
|
||||
onChange();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'array', label: 'Array' },
|
||||
{ value: 'str', label: 'String' },
|
||||
{ value: 'hex', label: 'Hex' },
|
||||
{ value: 'base64', label: 'Base64' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{delayAsNumber && (
|
||||
<Form.Item label="Delay (ms)">
|
||||
<InputNumber
|
||||
value={typeof item.delay === 'number' ? item.delay : 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
item.delay = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{item.type === 'array' ? (
|
||||
<>
|
||||
<Form.Item label="Rand">
|
||||
{delayAsString ? (
|
||||
<Input
|
||||
value={String(item.rand ?? '')}
|
||||
onChange={(e) => {
|
||||
item.rand = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
placeholder="0 or 1-8192"
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
value={typeof item.rand === 'number' ? item.rand : 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
item.rand = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item label="Rand Range">
|
||||
<Input
|
||||
value={item.randRange || ''}
|
||||
placeholder="0-255"
|
||||
onChange={(e) => {
|
||||
item.randRange = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item label="Packet">
|
||||
{item.type === 'base64' ? (
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
value={String(item.packet ?? '')}
|
||||
placeholder="binary data"
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
onChange={(e) => {
|
||||
item.packet = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
item.packet = RandomUtil.randomBase64();
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Input.Group>
|
||||
) : (
|
||||
<Input
|
||||
value={String(item.packet ?? '')}
|
||||
placeholder="binary data"
|
||||
onChange={(e) => {
|
||||
item.packet = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
{delayAsString && (
|
||||
<Form.Item label="Delay">
|
||||
<Input
|
||||
value={typeof item.delay === 'string' ? item.delay : ''}
|
||||
placeholder="10-20"
|
||||
onChange={(e) => {
|
||||
item.delay = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QuicParamsForm({ params, onChange }: { params: QuicParams; onChange: () => void }) {
|
||||
function update<K extends keyof QuicParams>(key: K, value: QuicParams[K]) {
|
||||
params[key] = value;
|
||||
onChange();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Congestion">
|
||||
<Select
|
||||
value={params.congestion}
|
||||
onChange={(v) => update('congestion', v)}
|
||||
options={[
|
||||
{ value: 'reno', label: 'Reno' },
|
||||
{ value: 'bbr', label: 'BBR' },
|
||||
{ value: 'brutal', label: 'Brutal' },
|
||||
{ value: 'force-brutal', label: 'Force Brutal' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Debug">
|
||||
<Switch checked={!!params.debug} onChange={(v) => update('debug', v)} />
|
||||
</Form.Item>
|
||||
{['brutal', 'force-brutal'].includes(params.congestion) && (
|
||||
<>
|
||||
<Form.Item label="Brutal Up">
|
||||
<Input
|
||||
value={String(params.brutalUp ?? '')}
|
||||
placeholder="65537"
|
||||
onChange={(e) => update('brutalUp', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Brutal Down">
|
||||
<Input
|
||||
value={String(params.brutalDown ?? '')}
|
||||
placeholder="65537"
|
||||
onChange={(e) => update('brutalDown', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item label="UDP Hop">
|
||||
<Switch checked={!!params.hasUdpHop} onChange={(v) => update('hasUdpHop', v)} />
|
||||
</Form.Item>
|
||||
{params.hasUdpHop && params.udpHop && (
|
||||
<>
|
||||
<Form.Item label="Hop Ports">
|
||||
<Input
|
||||
value={params.udpHop.ports || ''}
|
||||
placeholder="e.g. 20000-50000"
|
||||
onChange={(e) => {
|
||||
params.udpHop!.ports = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Hop Interval (s)">
|
||||
<InputNumber
|
||||
value={Number(params.udpHop.interval) || 5}
|
||||
min={5}
|
||||
onChange={(v) => {
|
||||
params.udpHop!.interval = Number(v) || 5;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{(
|
||||
[
|
||||
['maxIdleTimeout', 'Max Idle Timeout (s)', 4, 120],
|
||||
['keepAlivePeriod', 'Keep Alive Period (s)', 2, 60],
|
||||
] as const
|
||||
).map(([key, label, min, max]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
value={params[key] as number}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(v) => update(key, Number(v) || min)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item label="Disable Path MTU Dis">
|
||||
<Switch checked={!!params.disablePathMTUDiscovery} onChange={(v) => update('disablePathMTUDiscovery', v)} />
|
||||
</Form.Item>
|
||||
{(
|
||||
[
|
||||
['maxIncomingStreams', 'Max Incoming Streams', 8, '1024 = default'],
|
||||
['initStreamReceiveWindow', 'Init Stream Window', 16384, '8388608 = default'],
|
||||
['maxStreamReceiveWindow', 'Max Stream Window', 16384, '8388608 = default'],
|
||||
['initConnectionReceiveWindow', 'Init Conn Window', 16384, '20971520 = default'],
|
||||
['maxConnectionReceiveWindow', 'Max Conn Window', 16384, '20971520 = default'],
|
||||
] as const
|
||||
).map(([key, label, min, placeholder]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
value={params[key] as number}
|
||||
min={min}
|
||||
placeholder={placeholder}
|
||||
onChange={(v) => update(key, Number(v) || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
|
||||
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
|
||||
// inbound and outbound modals — they share the same StreamSettings
|
||||
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
|
||||
// component handles both. The host modal passes its protocol through
|
||||
// so we know whether to show only the Hysteria-specific UDP types.
|
||||
const props = defineProps({
|
||||
stream: { type: Object, required: true },
|
||||
protocol: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
|
||||
const network = computed(() => props.stream?.network || '');
|
||||
|
||||
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
|
||||
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
|
||||
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
|
||||
|
||||
// Reset the per-row settings shape when the user picks a different
|
||||
// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
|
||||
function changeMaskType(mask, type) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
// Special case from the legacy form: switching a UDP mask to xdns
|
||||
// shrinks the kcp MTU; everything else needs the default 1350.
|
||||
function changeUdpMaskType(mask, type) {
|
||||
changeMaskType(mask, type);
|
||||
if (network.value === 'kcp' && props.stream.kcp) {
|
||||
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
}
|
||||
|
||||
// header-custom and noise rows share the same per-item shape — the
|
||||
// type select rewires the packet field. Pulled out so the click
|
||||
// handlers in the template stay readable.
|
||||
function changeItemType(item, type) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') { item.rand = 0; item.packet = []; }
|
||||
else item.packet = '';
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
|
||||
props.stream.addUdpMask(def);
|
||||
}
|
||||
|
||||
function newClientServerItem() {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem() {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem() {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
|
||||
:wrapper-col="{ md: { span: 14 } }">
|
||||
<!-- ============================== TCP MASKS ============================== -->
|
||||
<template v-if="showTcp">
|
||||
<a-form-item label="TCP Masks">
|
||||
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
TCP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delTcpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
|
||||
<a-select-option value="fragment">Fragment</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Fragment -->
|
||||
<template v-if="mask.type === 'fragment'">
|
||||
<a-form-item label="Packets">
|
||||
<a-select v-model:value="mask.settings.packets">
|
||||
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||
<a-select-option value="1-3">1-3</a-select-option>
|
||||
<a-select-option value="1-5">1-5</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Length">
|
||||
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Split">
|
||||
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Sudoku -->
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom — clients/servers as 2D groups -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<!-- Clients -->
|
||||
<a-form-item label="Clients">
|
||||
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Clients Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.clients.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Servers -->
|
||||
<a-form-item label="Servers">
|
||||
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Servers Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.servers.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== UDP MASKS ============================== -->
|
||||
<template v-if="showUdp">
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delUdpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
|
||||
<template v-if="isHysteria">
|
||||
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
|
||||
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
|
||||
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
|
||||
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
|
||||
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
|
||||
:token-separators="[',']" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Noise -->
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model:value="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise {{ ni + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.noise.splice(ni, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="n.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="n.type === 'base64'" compact>
|
||||
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="n.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="n.delay" placeholder="10-20" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom (UDP) — flat client/server lists -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client {{ ci + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.client.splice(ci, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="c.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="c.type === 'base64'" compact>
|
||||
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="c.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-divider :style="{ margin: '0' }" />
|
||||
<a-form-item label="Server">
|
||||
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server {{ si + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.server.splice(si, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="s.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="s.type === 'base64'" compact>
|
||||
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="s.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- xICMP -->
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model:value="mask.settings.id" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== QUIC PARAMS ============================== -->
|
||||
<template v-if="showQuic">
|
||||
<a-form-item label="QUIC Params">
|
||||
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
|
||||
<a-form-item label="Congestion">
|
||||
<a-select v-model:value="stream.finalmask.quicParams.congestion">
|
||||
<a-select-option value="reno">Reno</a-select-option>
|
||||
<a-select-option value="bbr">BBR</a-select-option>
|
||||
<a-select-option value="brutal">Brutal</a-select-option>
|
||||
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Debug">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
|
||||
</a-form-item>
|
||||
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
|
||||
<a-form-item label="Brutal Up">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Brutal Down">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="UDP Hop">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
|
||||
<a-form-item label="Hop Ports">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Hop Interval (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Max Idle Timeout (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Disable Path MTU Dis">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Incoming Streams">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
|
||||
placeholder="1024 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
interface InfinityIconProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 640 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
style={{ verticalAlign: '-1px', display: 'inline-block' }}
|
||||
>
|
||||
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup>
|
||||
// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
|
||||
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
|
||||
// so the inbound list and client row table use this SVG instead. The
|
||||
// path matches what the legacy panel embedded.
|
||||
defineProps({
|
||||
width: { type: [String, Number], default: 14 },
|
||||
height: { type: [String, Number], default: 10 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
|
||||
style="vertical-align: -1px; display: inline-block;">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
.input-addon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 11px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.dark .input-addon,
|
||||
html[data-theme='ultra-dark'] .input-addon {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child) {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:first-child {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:last-child {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import './InputAddon.css';
|
||||
|
||||
interface InputAddonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
|
||||
return (
|
||||
<span
|
||||
className={`input-addon ${className}`.trim()}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.json-editor-host .cm-editor,
|
||||
.json-editor-host .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { lintGutter, linter } from '@codemirror/lint';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './JsonEditor.css';
|
||||
|
||||
export interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange?: (next: string) => void;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonEditorHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface DarkPalette {
|
||||
bg: string;
|
||||
panelBg: string;
|
||||
activeBg: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
}
|
||||
|
||||
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
|
||||
return EditorView.theme(
|
||||
{
|
||||
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||
'.cm-content': { caretColor: '#dcdcdc' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||
'.cm-gutters': {
|
||||
backgroundColor: bg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: '#6a6a6a',
|
||||
},
|
||||
'.cm-activeLine': { backgroundColor: activeBg },
|
||||
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{ backgroundColor: selection },
|
||||
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: panelBg,
|
||||
border: `1px solid ${border}`,
|
||||
color: '#dcdcdc',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
}
|
||||
|
||||
const darkTheme = buildDarkTheme({
|
||||
bg: '#1e1e1e',
|
||||
panelBg: '#2d2d30',
|
||||
activeBg: '#252526',
|
||||
border: '#3a3a3c',
|
||||
selection: '#3a3a3c',
|
||||
});
|
||||
|
||||
const ultraDarkTheme = buildDarkTheme({
|
||||
bg: '#0a0a0a',
|
||||
panelBg: '#141414',
|
||||
activeBg: '#141414',
|
||||
border: '#1f1f1f',
|
||||
selection: '#2a2a2a',
|
||||
});
|
||||
|
||||
function themeExtension(isDark: boolean, isUltra: boolean) {
|
||||
if (!isDark) return [];
|
||||
const chrome = isUltra ? ultraDarkTheme : darkTheme;
|
||||
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||
}
|
||||
|
||||
const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
|
||||
{ value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const themeCompartmentRef = useRef<Compartment>(new Compartment());
|
||||
const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const { isDark, isUltra } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => viewRef.current?.focus(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
|
||||
const updateListener = EditorView.updateListener.of((u) => {
|
||||
if (!u.docChanged) return;
|
||||
const next = u.state.doc.toString();
|
||||
if (next === valueRef.current) return;
|
||||
valueRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
json(),
|
||||
linter(jsonParseLinter()),
|
||||
lintGutter(),
|
||||
EditorView.lineWrapping,
|
||||
updateListener,
|
||||
themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
|
||||
readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (value === current) return;
|
||||
valueRef.current = value;
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
|
||||
});
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
return <div ref={hostRef} className="json-editor-host" />;
|
||||
});
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -1,185 +0,0 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { lintGutter, linter } from '@codemirror/lint';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
|
||||
import { theme as themeState } from '@/composables/useTheme.js';
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, default: '' },
|
||||
minHeight: { type: String, default: '320px' },
|
||||
maxHeight: { type: String, default: '600px' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value', 'change']);
|
||||
|
||||
const host = ref(null);
|
||||
let view = null;
|
||||
const themeCompartment = new Compartment();
|
||||
const readonlyCompartment = new Compartment();
|
||||
|
||||
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
|
||||
return EditorView.theme(
|
||||
{
|
||||
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||
'.cm-content': { caretColor: '#dcdcdc' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||
'.cm-gutters': {
|
||||
backgroundColor: bg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: '#6a6a6a',
|
||||
},
|
||||
'.cm-activeLine': { backgroundColor: activeBg },
|
||||
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{ backgroundColor: selection },
|
||||
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: panelBg,
|
||||
border: `1px solid ${border}`,
|
||||
color: '#dcdcdc',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
}
|
||||
|
||||
const darkTheme = buildDarkTheme({
|
||||
bg: '#1e1e1e',
|
||||
panelBg: '#2d2d30',
|
||||
activeBg: '#252526',
|
||||
border: '#3a3a3c',
|
||||
selection: '#3a3a3c',
|
||||
});
|
||||
|
||||
const ultraDarkTheme = buildDarkTheme({
|
||||
bg: '#0a0a0a',
|
||||
panelBg: '#141414',
|
||||
activeBg: '#141414',
|
||||
border: '#1f1f1f',
|
||||
selection: '#2a2a2a',
|
||||
});
|
||||
|
||||
function themeExtension() {
|
||||
if (!themeState.isDark) return [];
|
||||
const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
|
||||
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||
}
|
||||
|
||||
function readonlyExtension() {
|
||||
return EditorState.readOnly.of(props.readonly);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const updateListener = EditorView.updateListener.of((u) => {
|
||||
if (!u.docChanged) return;
|
||||
const next = u.state.doc.toString();
|
||||
if (next === props.value) return;
|
||||
emit('update:value', next);
|
||||
emit('change', next);
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
parent: host.value,
|
||||
state: EditorState.create({
|
||||
doc: props.value || '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
json(),
|
||||
linter(jsonParseLinter()),
|
||||
lintGutter(),
|
||||
EditorView.lineWrapping,
|
||||
updateListener,
|
||||
themeCompartment.of(themeExtension()),
|
||||
readonlyCompartment.of(readonlyExtension()),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
minHeight: props.minHeight,
|
||||
maxHeight: props.maxHeight,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => props.value, (next) => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (next === current) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: next || '' },
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => themeState.isDark, () => themeState.isUltra],
|
||||
() => {
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.readonly,
|
||||
() => {
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus: () => view?.focus(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="json-editor-host" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.json-editor-host :deep(.cm-editor),
|
||||
.json-editor-host :deep(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(body.dark) .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
:global(html[data-theme="ultra-dark"]) .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input, Modal } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
|
||||
interface PromptModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
okText?: string;
|
||||
type?: 'input' | 'textarea';
|
||||
initialValue?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function PromptModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
okText = 'OK',
|
||||
type = 'input',
|
||||
initialValue = '',
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: PromptModalProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
setTimeout(() => {
|
||||
if (type === 'textarea') textareaRef.current?.focus();
|
||||
else inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [open, initialValue, type]);
|
||||
|
||||
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
if (type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onConfirm(value);
|
||||
return;
|
||||
}
|
||||
if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
onConfirm(value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="Cancel"
|
||||
mask={{ closable: false }}
|
||||
confirmLoading={loading}
|
||||
onOk={() => onConfirm(value)}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
{type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
onKeyDown={onKeydown}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeydown}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Generic prompt modal — used by features like "import inbound" that
|
||||
// need a free-form text/textarea input and a confirm callback. The
|
||||
// parent owns the action; this component only surfaces the value via
|
||||
// the `confirm` event when the user clicks OK.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
okText: { type: String, default: 'OK' },
|
||||
// 'text' = single-line input; 'textarea' = multi-line.
|
||||
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
|
||||
initialValue: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const value = ref('');
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) value.value = props.initialValue;
|
||||
});
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function ok() { emit('confirm', value.value); }
|
||||
|
||||
// Enter submits when single-line; ctrl+S submits in textarea mode
|
||||
// (matches legacy keybindings).
|
||||
function onKeydown(e) {
|
||||
if (props.type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
return;
|
||||
}
|
||||
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
|
||||
:confirm-loading="loading" @ok="ok" @cancel="close">
|
||||
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
|
||||
@keydown="onKeydown" />
|
||||
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
.setting-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
.setting-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .setting-list-item,
|
||||
html[data-theme='ultra-dark'] .setting-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setting-list-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.setting-list-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-list-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
body.dark .setting-list-title,
|
||||
html[data-theme='ultra-dark'] .setting-list-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .setting-list-description,
|
||||
html[data-theme='ultra-dark'] .setting-list-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Col, Row } from 'antd';
|
||||
import './SettingListItem.css';
|
||||
|
||||
interface SettingListItemProps {
|
||||
paddings?: 'small' | 'default';
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
control?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SettingListItem({
|
||||
paddings = 'default',
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
control,
|
||||
}: SettingListItemProps) {
|
||||
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
||||
return (
|
||||
<div className="setting-list-item" style={{ padding }}>
|
||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="setting-list-meta">
|
||||
{title && <div className="setting-list-title">{title}</div>}
|
||||
{description && <div className="setting-list-description">{description}</div>}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
{control ?? children}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
paddings: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['small', 'default'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const padding = computed(() =>
|
||||
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list-item :style="{ padding }">
|
||||
<a-row :gutter="[8, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<template #description>
|
||||
<slot name="description" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<slot name="control" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import './Sparkline.css';
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
labels?: (string | number)[];
|
||||
vbWidth?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
maxPoints?: number;
|
||||
showGrid?: boolean;
|
||||
gridColor?: string;
|
||||
fillOpacity?: number;
|
||||
showMarker?: boolean;
|
||||
markerRadius?: number;
|
||||
showAxes?: boolean;
|
||||
yTickStep?: number;
|
||||
tickCountX?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
showTooltip?: boolean;
|
||||
valueMin?: number;
|
||||
valueMax?: number | null;
|
||||
yFormatter?: (v: number) => string;
|
||||
tooltipFormatter?: ((v: number) => string) | null;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
labels = [],
|
||||
vbWidth = 320,
|
||||
height = 80,
|
||||
stroke = '#008771',
|
||||
strokeWidth = 2,
|
||||
maxPoints = 120,
|
||||
showGrid = true,
|
||||
gridColor = 'rgba(0,0,0,0.08)',
|
||||
fillOpacity = 0.22,
|
||||
showMarker = true,
|
||||
markerRadius = 3,
|
||||
showAxes = false,
|
||||
yTickStep = 25,
|
||||
tickCountX = 4,
|
||||
paddingLeft = 56,
|
||||
paddingRight = 6,
|
||||
paddingTop = 6,
|
||||
paddingBottom = 20,
|
||||
showTooltip = false,
|
||||
valueMin = 0,
|
||||
valueMax = 100,
|
||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||
tooltipFormatter = null,
|
||||
}: SparklineProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(0);
|
||||
const [hoverIdx, setHoverIdx] = useState(-1);
|
||||
|
||||
const reactId = useId();
|
||||
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
const gradId = `spkGrad-${safeId}`;
|
||||
const shadowId = `spkShadow-${safeId}`;
|
||||
const glowId = `spkGlow-${safeId}`;
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) setMeasuredWidth(Math.round(w));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, []);
|
||||
|
||||
const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
|
||||
const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
|
||||
const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
|
||||
const nPoints = Math.min(data.length, maxPoints);
|
||||
|
||||
const dataSlice = useMemo(
|
||||
() => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
|
||||
[data, nPoints],
|
||||
);
|
||||
|
||||
const labelsSlice = useMemo(() => {
|
||||
if (!labels?.length || nPoints === 0) return [] as (string | number)[];
|
||||
const start = Math.max(0, labels.length - nPoints);
|
||||
return labels.slice(start);
|
||||
}, [labels, nPoints]);
|
||||
|
||||
const yDomain = useMemo(() => {
|
||||
const min = valueMin;
|
||||
if (valueMax != null) return { min, max: valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
}, [dataSlice, valueMin, valueMax]);
|
||||
|
||||
const project = useCallback(
|
||||
(v: number) => {
|
||||
const { min, max } = yDomain;
|
||||
const span = max - min;
|
||||
if (span <= 0) return paddingTop + drawHeight;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
|
||||
},
|
||||
[yDomain, paddingTop, drawHeight],
|
||||
);
|
||||
|
||||
const pointsArr = useMemo<[number, number][]>(() => {
|
||||
if (nPoints === 0) return [];
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
return dataSlice.map((v, i) => {
|
||||
const x = Math.round(paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
}, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
|
||||
|
||||
const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
|
||||
|
||||
const areaPath = useMemo(() => {
|
||||
if (pointsArr.length === 0) return '';
|
||||
const first = pointsArr[0];
|
||||
const last = pointsArr[pointsArr.length - 1];
|
||||
const baseY = paddingTop + drawHeight;
|
||||
const line = pointsStr.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
}, [pointsArr, pointsStr, paddingTop, drawHeight]);
|
||||
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return [];
|
||||
const h = drawHeight;
|
||||
const w = drawWidth;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(paddingTop + h * r);
|
||||
return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
|
||||
});
|
||||
}, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
|
||||
|
||||
const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
|
||||
|
||||
const yTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
const { min, max } = yDomain;
|
||||
const out: { y: number; label: string }[] = [];
|
||||
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += yTickStep) {
|
||||
out.push({ y: project(p), label: yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
if (nPoints === 0) return [];
|
||||
const m = Math.max(2, tickCountX);
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
const out: { x: number; label: string }[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (nPoints - 1)) / (m - 1));
|
||||
const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
|
||||
const x = Math.round(paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(evt: MouseEvent<SVGSVGElement>) => {
|
||||
if (!showTooltip || pointsArr.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth;
|
||||
const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
|
||||
setHoverIdx(idx);
|
||||
},
|
||||
[showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
|
||||
|
||||
const hoverText = useMemo(() => {
|
||||
const idx = hoverIdx;
|
||||
if (idx < 0 || idx >= dataSlice.length) return '';
|
||||
const raw = Number(dataSlice[idx] || 0);
|
||||
const fmt = tooltipFormatter || yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
|
||||
|
||||
const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
|
||||
const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
|
||||
const tooltipX = hoverPoint
|
||||
? Math.max(
|
||||
paddingLeft + 2,
|
||||
Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="sparkline-svg"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
|
||||
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
|
||||
<feOffset dx="0" dy="2" result="offsetBlur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.45" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id={glowId}>
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{showGrid && (
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={g.x1}
|
||||
y1={g.y1}
|
||||
x2={g.x2}
|
||||
y2={g.y2}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 5"
|
||||
className="cpu-grid-line"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showAxes && (
|
||||
<g>
|
||||
{yTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`y${i}`}
|
||||
className="cpu-grid-y-text"
|
||||
x={Math.max(0, paddingLeft - 6)}
|
||||
y={tk.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
{xTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`x${i}`}
|
||||
className="cpu-grid-x-text"
|
||||
x={tk.x}
|
||||
y={paddingTop + drawHeight + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
|
||||
<polyline
|
||||
points={pointsStr}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter={`url(#${shadowId})`}
|
||||
/>
|
||||
{showMarker && lastPoint && (
|
||||
<>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
|
||||
<animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
|
||||
<g>
|
||||
<line
|
||||
className="cpu-grid-h-line"
|
||||
x1={pointsArr[hoverIdx][0]}
|
||||
x2={pointsArr[hoverIdx][0]}
|
||||
y1={paddingTop}
|
||||
y2={paddingTop + drawHeight}
|
||||
stroke={stroke}
|
||||
strokeOpacity={0.45}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
<rect
|
||||
x={tooltipX}
|
||||
y={paddingTop + 2}
|
||||
width={tooltipPillWidth}
|
||||
height={18}
|
||||
rx={9}
|
||||
ry={9}
|
||||
className="cpu-tooltip-pill"
|
||||
fill={stroke}
|
||||
fillOpacity={0.92}
|
||||
/>
|
||||
<text
|
||||
className="cpu-tooltip-text"
|
||||
x={tooltipX + tooltipPillWidth / 2}
|
||||
y={paddingTop + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill="#fff"
|
||||
>
|
||||
{hoverText}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, required: true },
|
||||
labels: { type: Array, default: () => [] },
|
||||
vbWidth: { type: Number, default: 320 },
|
||||
height: { type: Number, default: 80 },
|
||||
stroke: { type: String, default: '#008771' },
|
||||
strokeWidth: { type: Number, default: 2 },
|
||||
maxPoints: { type: Number, default: 120 },
|
||||
showGrid: { type: Boolean, default: true },
|
||||
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||
fillOpacity: { type: Number, default: 0.15 },
|
||||
showMarker: { type: Boolean, default: true },
|
||||
markerRadius: { type: Number, default: 2.8 },
|
||||
showAxes: { type: Boolean, default: false },
|
||||
yTickStep: { type: Number, default: 25 },
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 56 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
showTooltip: { type: Boolean, default: false },
|
||||
// Value-range customization. When valueMax is null the chart auto-scales
|
||||
// to the running max of the data (useful for unbounded series like
|
||||
// network throughput or online clients). Defaults preserve the legacy
|
||||
// 0..100 percent behavior so existing callers don't need to change.
|
||||
valueMin: { type: Number, default: 0 },
|
||||
valueMax: { type: [Number, null], default: 100 },
|
||||
// Y-axis tick formatter. Receives the raw value, returns the label.
|
||||
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
|
||||
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
|
||||
tooltipFormatter: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const hoverIdx = ref(-1);
|
||||
|
||||
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
|
||||
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
|
||||
// stretches the X axis and squashes axis text horizontally on narrow
|
||||
// containers (mobile). Falls back to the prop until the first measure.
|
||||
const svgRef = ref(null);
|
||||
const measuredWidth = ref(0);
|
||||
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
|
||||
|
||||
let resizeObserver = null;
|
||||
function measure() {
|
||||
const el = svgRef.value;
|
||||
if (!el) return;
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) measuredWidth.value = Math.round(w);
|
||||
}
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
|
||||
resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(svgRef.value);
|
||||
} else {
|
||||
window.addEventListener('resize', measure);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
else window.removeEventListener('resize', measure);
|
||||
});
|
||||
|
||||
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
|
||||
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
|
||||
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||
|
||||
const dataSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
return props.data.slice(props.data.length - n);
|
||||
});
|
||||
|
||||
const labelsSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (!props.labels?.length || n === 0) return [];
|
||||
const start = Math.max(0, props.labels.length - n);
|
||||
return props.labels.slice(start);
|
||||
});
|
||||
|
||||
// Resolved domain. When valueMax is null we auto-scale; pad the upper
|
||||
// bound by 10% so the line never touches the top edge — looks more
|
||||
// natural and gives the axis a sane ceiling. Floor the dynamic range
|
||||
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
|
||||
const yDomain = computed(() => {
|
||||
const min = props.valueMin;
|
||||
if (props.valueMax != null) return { min, max: props.valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice.value) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
});
|
||||
|
||||
function project(v) {
|
||||
const { min, max } = yDomain.value;
|
||||
const span = max - min;
|
||||
if (span <= 0) return props.paddingTop + drawHeight.value;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
|
||||
}
|
||||
|
||||
const pointsArr = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const slice = dataSlice.value;
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
return slice.map((v, i) => {
|
||||
const x = Math.round(props.paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
});
|
||||
|
||||
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
|
||||
|
||||
const areaPath = computed(() => {
|
||||
if (pointsArr.value.length === 0) return '';
|
||||
const first = pointsArr.value[0];
|
||||
const last = pointsArr.value[pointsArr.value.length - 1];
|
||||
const baseY = props.paddingTop + drawHeight.value;
|
||||
const line = pointsStr.value.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
});
|
||||
|
||||
const gridLines = computed(() => {
|
||||
if (!props.showGrid) return [];
|
||||
const h = drawHeight.value;
|
||||
const w = drawWidth.value;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(props.paddingTop + h * r);
|
||||
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
|
||||
});
|
||||
});
|
||||
|
||||
const lastPoint = computed(() => {
|
||||
if (pointsArr.value.length === 0) return null;
|
||||
return pointsArr.value[pointsArr.value.length - 1];
|
||||
});
|
||||
|
||||
// Y-axis tick rendering. We pick a small number of evenly spaced values
|
||||
// inside the resolved domain and run them through yFormatter — that's
|
||||
// what makes "MB/s" / "clients" / "%" all render correctly without the
|
||||
// caller having to subclass the component.
|
||||
const yTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const { min, max } = yDomain.value;
|
||||
const out = [];
|
||||
// For percent-style domains keep the legacy fixed step; otherwise
|
||||
// default to 4 evenly spaced ticks (5 lines including the bottom).
|
||||
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += props.yTickStep) {
|
||||
const y = project(p);
|
||||
out.push({ y, label: props.yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: props.yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const xTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const labels = labelsSlice.value;
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const m = Math.max(2, props.tickCountX);
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
const out = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (n - 1)) / (m - 1));
|
||||
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
|
||||
const x = Math.round(props.paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onMouseMove(evt) {
|
||||
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth.value;
|
||||
const n = nPoints.value;
|
||||
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||
hoverIdx.value = idx;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hoverIdx.value = -1;
|
||||
}
|
||||
|
||||
function fmtHoverText() {
|
||||
const idx = hoverIdx.value;
|
||||
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||
const raw = Number(dataSlice.value[idx] || 0);
|
||||
const fmt = props.tooltipFormatter || props.yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}
|
||||
|
||||
// Stable per-instance gradient id so multiple sparklines on a page
|
||||
// don't clobber each other's <defs id="spkGrad">.
|
||||
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
|
||||
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<defs>
|
||||
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
|
||||
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g v-if="showGrid">
|
||||
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
|
||||
stroke-width="1" class="cpu-grid-line" />
|
||||
</g>
|
||||
|
||||
<g v-if="showAxes">
|
||||
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
|
||||
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
|
||||
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
|
||||
text-anchor="middle" font-size="10">{{ t.label }}</text>
|
||||
</g>
|
||||
|
||||
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
|
||||
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
|
||||
|
||||
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
|
||||
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
|
||||
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
|
||||
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
|
||||
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
|
||||
font-size="11">{{ fmtHoverText() }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
|
||||
reliably hash-attribute SVG descendants, so the dark-mode overrides
|
||||
have to live in a non-scoped block to actually take effect. The
|
||||
numbers are also small, so the dark-theme fills run at ~85% opacity
|
||||
for legibility (the previous 55% was washed out on navy backgrounds). -->
|
||||
<style>
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-h-line {
|
||||
stroke: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -1,311 +0,0 @@
|
||||
<script>
|
||||
// Use defineComponent so we can keep the parent + child components in
|
||||
// the same file with the provide() <-> inject relationship intact.
|
||||
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
|
||||
import { DragOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const ROW_CLASS = 'sortable-row';
|
||||
|
||||
// Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||
//
|
||||
// Why a custom component:
|
||||
// - Old impl set draggable: true on every row, which broke text selection
|
||||
// in cells and let HTML5 start drags from anywhere on the row. This
|
||||
// version only initiates drag from an explicit handle, via Pointer
|
||||
// Events (one API for mouse + touch + pen).
|
||||
// - During drag, data-source is reordered live; the source row visually
|
||||
// slides into the target slot. The live reorder IS the visual feedback.
|
||||
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
|
||||
// before so existing call sites stay unchanged.
|
||||
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
|
||||
// one; Escape cancels an in-flight drag.
|
||||
|
||||
export const TableSortableTrigger = defineComponent({
|
||||
name: 'TableSortableTrigger',
|
||||
props: {
|
||||
itemIndex: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const sortable = inject('sortable', null);
|
||||
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
|
||||
|
||||
function onPointerDown(e) {
|
||||
sortable?.startDrag?.(e, props.itemIndex);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const move = sortable?.moveByKeyboard;
|
||||
if (!move) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1, props.itemIndex);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(+1, props.itemIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return () => h(DragOutlined, {
|
||||
class: 'sortable-icon',
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-label': ariaLabel.value,
|
||||
onPointerdown: onPointerDown,
|
||||
onKeydown: onKeyDown,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableSortable',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
customRow: { type: Function, default: null },
|
||||
rowKey: { type: [String, Function], default: null },
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
|
||||
},
|
||||
},
|
||||
emits: ['onsort'],
|
||||
setup(props, { emit, slots, attrs, expose }) {
|
||||
// null when idle; while dragging:
|
||||
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||
const drag = ref(null);
|
||||
const rootRef = ref(null);
|
||||
|
||||
const isDragging = computed(() => drag.value !== null);
|
||||
|
||||
// Resolve the row key for a record. Used to identify the source row
|
||||
// even after data-source is reordered live during drag.
|
||||
function keyOf(record, fallback) {
|
||||
const rk = props.rowKey;
|
||||
if (typeof rk === 'function') return rk(record);
|
||||
if (typeof rk === 'string') return record?.[rk];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function attachListeners() {
|
||||
document.addEventListener('pointermove', onPointerMove, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', cancelDrag, true);
|
||||
document.addEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', cancelDrag, true);
|
||||
document.removeEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function startDrag(e, sourceIndex) {
|
||||
// Primary button only (mouse left / first touch).
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const record = props.dataSource?.[sourceIndex];
|
||||
drag.value = {
|
||||
sourceIndex,
|
||||
targetIndex: sourceIndex,
|
||||
pointerId: e.pointerId,
|
||||
sourceKey: keyOf(record, sourceIndex),
|
||||
};
|
||||
// Capture the pointer so move/up keep firing even if the cursor
|
||||
// leaves the icon. Try/catch — some older browsers throw on capture.
|
||||
if (e.target?.setPointerCapture && e.pointerId != null) {
|
||||
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
}
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
|
||||
if (!rows.length) return;
|
||||
const y = e.clientY;
|
||||
const firstRect = rows[0].getBoundingClientRect();
|
||||
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||
let target = d.targetIndex;
|
||||
if (y < firstRect.top) {
|
||||
target = 0;
|
||||
} else if (y > lastRect.bottom) {
|
||||
target = rows.length - 1;
|
||||
} else {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect();
|
||||
if (y >= rect.top && y <= rect.bottom) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target !== d.targetIndex) {
|
||||
drag.value = { ...d, targetIndex: target };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
detachListeners();
|
||||
const captured = d;
|
||||
drag.value = null;
|
||||
if (captured.sourceIndex !== captured.targetIndex) {
|
||||
emit('onsort', captured.sourceIndex, captured.targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDrag(e) {
|
||||
// Triggered by pointercancel and keydown. For keydown only act on
|
||||
// Escape; otherwise let the event propagate.
|
||||
if (e?.type === 'keydown' && e.key !== 'Escape') return;
|
||||
detachListeners();
|
||||
drag.value = null;
|
||||
}
|
||||
|
||||
function moveByKeyboard(direction, sourceIndex) {
|
||||
const target = sourceIndex + direction;
|
||||
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
|
||||
emit('onsort', sourceIndex, target);
|
||||
}
|
||||
|
||||
function customRowRender(record, index) {
|
||||
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
|
||||
const d = drag.value;
|
||||
const isSource = d && keyOf(record, index) === d.sourceKey;
|
||||
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
|
||||
// no nested props/on like Vue 2.
|
||||
return {
|
||||
...parent,
|
||||
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||
// When idle the original list is returned unchanged so a-table can
|
||||
// diff against a stable reference.
|
||||
const records = computed(() => {
|
||||
const d = drag.value;
|
||||
const src = props.dataSource ?? [];
|
||||
if (!d || d.sourceIndex === d.targetIndex) return src;
|
||||
const list = src.slice();
|
||||
const [item] = list.splice(d.sourceIndex, 1);
|
||||
list.splice(d.targetIndex, 0, item);
|
||||
return list;
|
||||
});
|
||||
|
||||
expose({ startDrag, moveByKeyboard });
|
||||
|
||||
return {
|
||||
rootRef, drag, isDragging, records, slots, attrs,
|
||||
startDrag, moveByKeyboard, customRowRender,
|
||||
};
|
||||
},
|
||||
// provide() needs to live at the options level so child components in
|
||||
// the rendered subtree resolve the same instance methods.
|
||||
provide() {
|
||||
return {
|
||||
sortable: {
|
||||
startDrag: (...a) => this.startDrag(...a),
|
||||
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
|
||||
},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', this.cancelDrag, true);
|
||||
document.removeEventListener('keydown', this.cancelDrag, true);
|
||||
},
|
||||
render() {
|
||||
// Forward every passed slot to a-table by reusing the slot fn
|
||||
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
|
||||
const tableSlots = {};
|
||||
for (const name of Object.keys(this.slots)) {
|
||||
tableSlots[name] = this.slots[name];
|
||||
}
|
||||
// Resolved at runtime so the user's app.use(Antd) registration wins;
|
||||
// avoids importing Table directly here.
|
||||
const ATable = resolveComponent('a-table');
|
||||
return h(
|
||||
'div',
|
||||
{ ref: 'rootRef' },
|
||||
[h(
|
||||
ATable,
|
||||
{
|
||||
...this.attrs,
|
||||
'data-source': this.records,
|
||||
'row-key': this.rowKey,
|
||||
customRow: this.customRowRender,
|
||||
locale: this.locale,
|
||||
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
|
||||
},
|
||||
tableSlots,
|
||||
)],
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sortable-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sortable-icon:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sortable-icon:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sortable-icon:focus-visible {
|
||||
outline: 2px solid #008771;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.light .sortable-icon {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.light .sortable-icon:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row>td {
|
||||
background: rgba(0, 135, 113, 0.10) !important;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-row>td {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging,
|
||||
.sortable-table-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button, Input, Modal, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
interface TextModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(content || '');
|
||||
if (ok) {
|
||||
messageApi.success('Copied');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!fileName) return;
|
||||
FileManager.downloadTextFile(content, fileName);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<>
|
||||
{fileName && (
|
||||
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup>
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
// Read-only text modal — used to surface multi-line export blobs
|
||||
// (subscription URLs, raw inbound JSON, generated share links) the
|
||||
// way the legacy txtModal did.
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
// When set, surfaces a download button that writes `content` to a
|
||||
// text file with this name.
|
||||
fileName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
async function copy(value) {
|
||||
const ok = await ClipboardManager.copyText(value || '');
|
||||
if (ok) {
|
||||
message.success('Copied');
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function download(content, name) {
|
||||
if (!name) return;
|
||||
FileManager.downloadTextFile(content, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
|
||||
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
|
||||
<template #footer>
|
||||
<a-button v-if="fileName" @click="download(content, fileName)">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
{{ fileName }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="copy(content)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-modal-content {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
|
||||
// Loaded from /panel/setting/defaultSettings on first use, so any
|
||||
// component (modals, inbound forms, future pages) can read the same
|
||||
// value without prop-drilling and without re-fetching.
|
||||
//
|
||||
// useInbounds (which already reads defaultSettings for its own state)
|
||||
// calls setDatepicker() after its fetch so we don't issue a second
|
||||
// HTTP round-trip on the inbounds page.
|
||||
|
||||
import { readonly, ref } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const datepicker = ref('gregorian');
|
||||
let fetched = false;
|
||||
let pending = null;
|
||||
|
||||
async function loadOnce() {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (msg?.success) {
|
||||
datepicker.value = msg.obj?.datepicker || 'gregorian';
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value) {
|
||||
fetched = true;
|
||||
datepicker.value = value || 'gregorian';
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
loadOnce();
|
||||
return { datepicker: readonly(datepicker) };
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
|
||||
// `isMobile` ref that updates on window resize. Use inside <script setup>:
|
||||
//
|
||||
// const { isMobile } = useMediaQuery();
|
||||
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
|
||||
const compute = () => window.innerWidth <= breakpoint;
|
||||
const isMobile = ref(compute());
|
||||
|
||||
const onResize = () => {
|
||||
isMobile.value = compute();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Lightweight composable that fetches the node list once on mount and
|
||||
// exposes id→name + id→online lookups. Used by the Inbounds page so it
|
||||
// can render a Node selector and a Node column without pulling the
|
||||
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
|
||||
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export function useNodeList() {
|
||||
const nodes = ref([]);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||
if (msg?.success) {
|
||||
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
|
||||
const byId = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of nodes.value) m.set(n.id, n);
|
||||
return m;
|
||||
});
|
||||
|
||||
function nameFor(id) {
|
||||
if (id == null) return null;
|
||||
return byId.value.get(id)?.name || null;
|
||||
}
|
||||
|
||||
function isOnline(id) {
|
||||
if (id == null) return true;
|
||||
const n = byId.value.get(id);
|
||||
return n != null && n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
const hasActive = computed(() => nodes.value.some((n) => n.enable));
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// Polls /panel/api/server/status and exposes a reactive Status object
|
||||
// + a `fetched` flag so consumers can show a spinner before the first
|
||||
// successful fetch.
|
||||
//
|
||||
// WebSocket integration is intentionally deferred to a later sub-phase.
|
||||
// Polling at 2s is the same fallback the legacy panel falls back to
|
||||
// when its websocket link drops, so we're shipping the proven path
|
||||
// first and adding the websocket on top later.
|
||||
export function useStatus() {
|
||||
const status = shallowRef(new Status());
|
||||
const fetched = ref(false);
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
status.value = new Status(msg.obj);
|
||||
if (!fetched.value) fetched.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { reactive, computed, watchEffect } from 'vue';
|
||||
import { theme as antdTheme } from 'ant-design-vue';
|
||||
|
||||
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
|
||||
// from any component to read/toggle. Boot side-effects (apply current
|
||||
// theme to <body>/<html>) run once at module load so the page is in the
|
||||
// right theme before Vue mounts.
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key, fallback) {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
const isDark = readBool(STORAGE_DARK, true);
|
||||
const isUltra = readBool(STORAGE_ULTRA, false);
|
||||
|
||||
export const theme = reactive({
|
||||
isDark,
|
||||
isUltra,
|
||||
});
|
||||
|
||||
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
||||
|
||||
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
||||
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
|
||||
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
|
||||
// so the panel reads as a familiar modern IDE rather than the older
|
||||
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#1e1e1e',
|
||||
colorBgLayout: '#1e1e1e',
|
||||
colorBgContainer: '#252526',
|
||||
colorBgElevated: '#2d2d30',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#0a0a0a',
|
||||
colorBgElevated: '#141414',
|
||||
};
|
||||
|
||||
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
|
||||
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||
// index.js). Override at the component-token level so the sider blends
|
||||
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
|
||||
// `#252526` / `#333333` tones VS Code does for its activity bar.
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#252526',
|
||||
colorBgTrigger: '#333333',
|
||||
colorBgBody: '#1e1e1e',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0a0a0a',
|
||||
colorBgTrigger: '#141414',
|
||||
colorBgBody: '#000',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#252526',
|
||||
colorSubItemBg: '#1e1e1e',
|
||||
menuSubMenuBg: '#252526',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0a0a0a',
|
||||
colorSubItemBg: '#000',
|
||||
menuSubMenuBg: '#0a0a0a',
|
||||
};
|
||||
|
||||
export const antdThemeConfig = computed(() => {
|
||||
if (!theme.isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function toggleTheme() {
|
||||
theme.isDark = !theme.isDark;
|
||||
}
|
||||
|
||||
export function toggleUltra() {
|
||||
theme.isUltra = !theme.isUltra;
|
||||
}
|
||||
|
||||
// Briefly disable theme transition animations while a toggle is in
|
||||
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
|
||||
// behavior of preventing flicker when hovering the theme menu.
|
||||
export function pauseAnimationsUntilLeave(elementId) {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
// Apply theme to DOM and persist whenever it changes.
|
||||
watchEffect(() => {
|
||||
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
|
||||
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
|
||||
|
||||
if (theme.isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
|
||||
|
||||
// Keep the global #message container's class in sync so AD-Vue toasts
|
||||
// pick up the right styling.
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
|
||||
// One client per browser tab (= per multi-page entry). WebSocketClient is
|
||||
// idempotent: repeated connect() calls while the socket is already open
|
||||
// are no-ops, so multiple components on the same page can share a single
|
||||
// underlying connection without each spawning their own.
|
||||
let sharedClient = null;
|
||||
|
||||
function getSharedClient() {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
// useWebSocket lets a Vue component subscribe to live server-pushed
|
||||
// events. Pass a map of { eventName: handler } and the composable wires
|
||||
// connect()/disconnect() into the component lifecycle and unsubscribes
|
||||
// every handler on unmount so a stale closure can't fire after the
|
||||
// page has moved on.
|
||||
//
|
||||
// Example:
|
||||
// useWebSocket({
|
||||
// traffic: (payload) => applyTrafficEvent(payload),
|
||||
// client_stats: (payload) => applyClientStatsEvent(payload),
|
||||
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
|
||||
// });
|
||||
//
|
||||
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
|
||||
// can be subscribed to alongside server-emitted types.
|
||||
export function useWebSocket(handlers) {
|
||||
const client = getSharedClient();
|
||||
const entries = Object.entries(handlers || {});
|
||||
|
||||
onMounted(() => {
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const [event, fn] of entries) client.off(event, fn);
|
||||
// Don't disconnect — another mounted component on the same page may
|
||||
// still be subscribed. The client closes naturally on page unload.
|
||||
});
|
||||
|
||||
return { client };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ApiDocsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ClientsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<InboundsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import IndexPage from '@/pages/index/IndexPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<IndexPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing this module triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue renders anything.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import LoginPage from '@/pages/login/LoginPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<LoginPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import NodesPage from '@/pages/nodes/NodesPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<NodesPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SettingsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
// The sub page is served by the subscription HTTP server (sub/sub.go)
|
||||
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
|
||||
// with the parsed traffic/quota/expiry view-model and the rendered
|
||||
// share links — the SPA reads those at mount.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import SubPage from '@/pages/sub/SubPage.vue';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(SubPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SubPage from '@/pages/sub/SubPage';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SubPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import XrayPage from '@/pages/xray/XrayPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<XrayPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface SubPageData {
|
||||
sId?: string;
|
||||
enabled?: boolean;
|
||||
download?: string;
|
||||
upload?: string;
|
||||
total?: string;
|
||||
used?: string;
|
||||
remained?: string;
|
||||
totalByte?: string | number;
|
||||
expire?: string | number;
|
||||
lastOnline?: string | number;
|
||||
subUrl?: string;
|
||||
subJsonUrl?: string;
|
||||
subClashUrl?: string;
|
||||
subTitle?: string;
|
||||
links?: string[];
|
||||
datepicker?: 'gregorian' | 'jalalian';
|
||||
downloadByte?: string | number;
|
||||
uploadByte?: string | number;
|
||||
usedByte?: string | number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
X_UI_BASE_PATH?: string;
|
||||
X_UI_CUR_VER?: string;
|
||||
__SUB_PAGE_DATA__?: SubPageData;
|
||||
}
|
||||
|
||||
declare module 'persian-calendar-suite' {
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
type DateInput = string | number | null;
|
||||
type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
|
||||
|
||||
interface PersianDateTimePickerProps {
|
||||
value?: DateInput;
|
||||
onChange?: (value: number | string | null) => void;
|
||||
defaultValue?: string | number | 'now' | null;
|
||||
showTime?: boolean;
|
||||
minuteStep?: number;
|
||||
outputFormat?: OutputFormat;
|
||||
showFooter?: boolean;
|
||||
theme?: Record<string, unknown>;
|
||||
disabledHours?: number[];
|
||||
minDate?: string | Date | null;
|
||||
maxDate?: string | Date | null;
|
||||
enabledDates?: string[] | null;
|
||||
disabledDates?: string[] | null;
|
||||
disabledWeekDays?: number[];
|
||||
persianNumbers?: boolean;
|
||||
rtlCalendar?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const PersianDateTimePicker: ComponentType<PersianDateTimePickerProps>;
|
||||
export const PersianCalendar: ComponentType<Record<string, unknown>>;
|
||||
export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
|
||||
export const PersianTimePicker: ComponentType<Record<string, unknown>>;
|
||||
export const PersianTimeline: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
export function useAllSetting() {
|
||||
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const applyServerState = useCallback((obj: unknown) => {
|
||||
setAllSetting(new AllSetting(obj));
|
||||
setOldAllSetting(new AllSetting(obj));
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
|
||||
if (msg?.success) {
|
||||
applyServerState(msg.obj);
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}, [applyServerState]);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [allSetting, fetchAll]);
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setAllSetting((prev) => {
|
||||
const next = new AllSetting(prev);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveDisabled = useMemo(
|
||||
() => allSetting.equals(oldAllSetting),
|
||||
[allSetting, oldAllSetting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
return {
|
||||
allSetting,
|
||||
updateSetting,
|
||||
fetched,
|
||||
spinning,
|
||||
setSpinning,
|
||||
saveDisabled,
|
||||
fetchAll,
|
||||
saveAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
export interface ClientTraffic {
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
expiryTime?: number;
|
||||
enable?: boolean;
|
||||
lastOnline?: number;
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
email: string;
|
||||
subId?: string;
|
||||
uuid?: string;
|
||||
password?: string;
|
||||
auth?: string;
|
||||
flow?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
limitIp?: number;
|
||||
tgId?: number | string;
|
||||
comment?: string;
|
||||
enable?: boolean;
|
||||
inboundIds?: number[];
|
||||
traffic?: ClientTraffic;
|
||||
reverse?: { tag?: string };
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface InboundOption {
|
||||
id: number;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
tlsFlowCapable?: boolean;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
}
|
||||
|
||||
export function useClients() {
|
||||
const [clients, setClients] = useState<ClientRecord[]>([]);
|
||||
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
|
||||
const [onlines, setOnlines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
|
||||
});
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [expireDiff, setExpireDiff] = useState(0);
|
||||
const [trafficDiff, setTrafficDiff] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(0);
|
||||
|
||||
const clientsRef = useRef<ClientRecord[]>([]);
|
||||
const invalidateTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => { clientsRef.current = clients; }, [clients]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [clientsMsg, inboundsMsg] = await Promise.all([
|
||||
HttpUtil.get('/panel/api/clients/list') as Promise<ApiMsg<ClientRecord[]>>,
|
||||
HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>,
|
||||
]);
|
||||
if (clientsMsg?.success) {
|
||||
setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []);
|
||||
}
|
||||
if (inboundsMsg?.success) {
|
||||
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
|
||||
}
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSubSettings = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
setSubSettings({
|
||||
enable: !!s.subEnable,
|
||||
subURI: (s.subURI as string) || '',
|
||||
subJsonURI: (s.subJsonURI as string) || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
});
|
||||
setIpLimitEnable(!!s.ipLimitEnable);
|
||||
setTgBotEnable(!!s.tgBotEnable);
|
||||
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
|
||||
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
|
||||
setPageSize((s.pageSize as number) ?? 0);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: unknown) => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (email: string, client: unknown) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (email: string, 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) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
|
||||
}));
|
||||
await refresh();
|
||||
return results;
|
||||
}, [refresh]);
|
||||
|
||||
const attach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const detach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const resetTraffic = useCallback(async (client: ClientRecord) => {
|
||||
if (!client?.email) return null;
|
||||
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const resetAllTraffics = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const delDepleted = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
|
||||
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);
|
||||
}, [update]);
|
||||
|
||||
const applyTrafficEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { onlineClients?: string[] };
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
setOnlines(p.onlineClients);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
const cur = clientsRef.current || [];
|
||||
let touched = false;
|
||||
const next = cur.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(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) setClients(next);
|
||||
}, []);
|
||||
|
||||
const applyInvalidate = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { type?: string };
|
||||
if (p.type !== 'inbounds' && p.type !== 'clients') return;
|
||||
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
|
||||
invalidateTimerRef.current = window.setTimeout(() => {
|
||||
invalidateTimerRef.current = null;
|
||||
refresh();
|
||||
}, 200);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Promise.all([refresh(), fetchSubSettings()]);
|
||||
|
||||
}, [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,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
type Calendar = 'gregorian' | 'jalalian';
|
||||
|
||||
let cachedValue: Calendar = 'gregorian';
|
||||
let fetched = false;
|
||||
let pending: Promise<void> | null = null;
|
||||
const listeners = new Set<(value: Calendar) => void>();
|
||||
|
||||
function notify(value: Calendar) {
|
||||
listeners.forEach((fn) => fn(value));
|
||||
}
|
||||
|
||||
async function loadOnce(): Promise<void> {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
|
||||
success?: boolean;
|
||||
obj?: { datepicker?: Calendar };
|
||||
};
|
||||
if (msg?.success) {
|
||||
cachedValue = msg.obj?.datepicker || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value: Calendar) {
|
||||
fetched = true;
|
||||
cachedValue = value || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
const [datepicker, setLocal] = useState<Calendar>(cachedValue);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setLocal);
|
||||
loadOnce();
|
||||
return () => {
|
||||
listeners.delete(setLocal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { datepicker };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [breakpoint]);
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export interface NodeRecord {
|
||||
id: number;
|
||||
name?: string;
|
||||
remark?: string;
|
||||
scheme?: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
apiToken?: string;
|
||||
enable?: boolean;
|
||||
status?: 'online' | 'offline' | string;
|
||||
latencyMs?: number;
|
||||
cpuPct?: number;
|
||||
memPct?: number;
|
||||
xrayVersion?: string;
|
||||
panelVersion?: string;
|
||||
uptimeSecs?: number;
|
||||
inboundCount?: number;
|
||||
clientCount?: number;
|
||||
onlineCount?: number;
|
||||
depletedCount?: number;
|
||||
lastHeartbeat?: number;
|
||||
lastError?: string;
|
||||
allowPrivateAddress?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface NodeTotals {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
avgLatency: number;
|
||||
inbounds: number;
|
||||
clients: number;
|
||||
onlineClients: number;
|
||||
depleted: number;
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
|
||||
if (msg?.success) {
|
||||
setNodes(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
}
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyNodesEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) {
|
||||
setNodes(payload as NodeRecord[]);
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (id: number, enable: boolean) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}, []);
|
||||
|
||||
const probe = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const totals = useMemo<NodeTotals>(() => {
|
||||
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 nodes) {
|
||||
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 && n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: nodes.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
inbounds,
|
||||
clients,
|
||||
onlineClients,
|
||||
depleted,
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
refresh,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export function useStatus() {
|
||||
const [status, setStatus] = useState<Status>(() => new Status());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
setStatus(new Status(msg.obj));
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme as antdTheme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd';
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key: string, fallback: boolean): boolean {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||
document.body.setAttribute('class', isDark ? 'dark' : 'light');
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// module load so the document is in the right theme before React mounts.
|
||||
const initialDark = readBool(STORAGE_DARK, true);
|
||||
const initialUltra = readBool(STORAGE_ULTRA, false);
|
||||
applyDom(initialDark, initialUltra);
|
||||
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#1a1b1f',
|
||||
colorBgLayout: '#1a1b1f',
|
||||
colorBgContainer: '#23252b',
|
||||
colorBgElevated: '#2d2f37',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#101013',
|
||||
colorBgElevated: '#1a1a1e',
|
||||
};
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
bodyBg: '#1a1b1f',
|
||||
headerBg: '#15161a',
|
||||
headerColor: '#ffffff',
|
||||
footerBg: '#1a1b1f',
|
||||
siderBg: '#15161a',
|
||||
triggerBg: '#23252b',
|
||||
triggerColor: '#ffffff',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
bodyBg: '#000',
|
||||
headerBg: '#050507',
|
||||
headerColor: '#ffffff',
|
||||
footerBg: '#000',
|
||||
siderBg: '#050507',
|
||||
triggerBg: '#1a1a1e',
|
||||
triggerColor: '#ffffff',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
darkItemBg: '#15161a',
|
||||
darkSubMenuItemBg: '#1a1b1f',
|
||||
darkPopupBg: '#23252b',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
darkItemBg: '#050507',
|
||||
darkSubMenuItemBg: '#000',
|
||||
darkPopupBg: '#101013',
|
||||
};
|
||||
|
||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||
if (!isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pauseAnimationsUntilLeave(elementId: string): void {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
toggleTheme: () => void;
|
||||
toggleUltra: () => void;
|
||||
antdThemeConfig: ThemeConfig;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
||||
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
||||
|
||||
useEffect(() => {
|
||||
applyDom(isDark, isUltra);
|
||||
localStorage.setItem(STORAGE_DARK, String(isDark));
|
||||
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
|
||||
const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
|
||||
|
||||
const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
|
||||
[isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
export function useWebSocket(handlers: Record<string, Handler>) {
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
const entries = Object.entries(handlers);
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
return () => {
|
||||
for (const [event, fn] of entries) client.off(event, fn);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
|
||||
const DIRTY_POLL_MS = 1000;
|
||||
|
||||
export interface OutboundTrafficRow {
|
||||
tag: string;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export interface OutboundTestResult {
|
||||
success: boolean;
|
||||
delay?: number;
|
||||
error?: string;
|
||||
mode?: string;
|
||||
ttfbMs?: number;
|
||||
tlsMs?: number;
|
||||
connectMs?: number;
|
||||
dnsMs?: number;
|
||||
statusCode?: number;
|
||||
endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
|
||||
}
|
||||
|
||||
export interface OutboundTestState {
|
||||
testing?: boolean;
|
||||
result?: OutboundTestResult | null;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface XraySettingsValue {
|
||||
inbounds?: unknown[];
|
||||
outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
|
||||
routing?: {
|
||||
rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
|
||||
balancers?: unknown[];
|
||||
domainStrategy?: string;
|
||||
};
|
||||
dns?: { tag?: string; servers?: unknown[] };
|
||||
log?: Record<string, unknown>;
|
||||
policy?: { system?: Record<string, boolean> };
|
||||
observatory?: unknown;
|
||||
burstObservatory?: unknown;
|
||||
fakedns?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SetTemplate = (
|
||||
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
||||
) => void;
|
||||
|
||||
export interface UseXraySettingResult {
|
||||
fetched: boolean;
|
||||
spinning: boolean;
|
||||
saveDisabled: boolean;
|
||||
fetchError: string;
|
||||
xraySetting: string;
|
||||
setXraySetting: (next: string) => void;
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundTestUrl: string;
|
||||
setOutboundTestUrl: (v: string) => void;
|
||||
inboundTags: string[];
|
||||
clientReverseTags: string[];
|
||||
restartResult: string;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
testingAll: boolean;
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchOutboundsTraffic: () => Promise<void>;
|
||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
||||
applyOutboundsEvent: (payload: unknown) => void;
|
||||
testOutbound: (
|
||||
index: number,
|
||||
outbound: unknown,
|
||||
mode?: string,
|
||||
) => Promise<OutboundTestResult | null>;
|
||||
testAllOutbounds: (mode?: string) => Promise<void>;
|
||||
saveAll: () => Promise<void>;
|
||||
resetToDefault: () => Promise<void>;
|
||||
restartXray: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useXraySetting(): UseXraySettingResult {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204');
|
||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [restartResult, setRestartResult] = useState('');
|
||||
const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const oldXraySettingRef = useRef('');
|
||||
const oldOutboundTestUrlRef = useRef('');
|
||||
const syncingRef = useRef(false);
|
||||
const xraySettingRef = useRef('');
|
||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||
|
||||
xraySettingRef.current = xraySetting;
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
|
||||
const setXraySetting = useCallback((next: string) => {
|
||||
setXraySettingState(next);
|
||||
if (syncingRef.current) return;
|
||||
try {
|
||||
const parsed = JSON.parse(next);
|
||||
syncingRef.current = true;
|
||||
setTemplateSettingsState(parsed);
|
||||
syncingRef.current = false;
|
||||
} catch {
|
||||
/* ignore — wait for user to finish */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
|
||||
setTemplateSettingsState((prev) => {
|
||||
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
|
||||
if (next == null) return next;
|
||||
if (!syncingRef.current) {
|
||||
try {
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(JSON.stringify(next, null, 2));
|
||||
} finally {
|
||||
syncingRef.current = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOutboundTestUrl = useCallback((v: string) => {
|
||||
setOutboundTestUrlState(v);
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setFetchError('');
|
||||
const msg = await HttpUtil.post('/panel/xray/');
|
||||
if (!msg?.success) {
|
||||
setFetchError(msg?.msg || 'Failed to load xray config');
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(msg.obj);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
setFetchError(`Malformed xray config response: ${err?.message || String(err)}`);
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
oldXraySettingRef.current = pretty;
|
||||
syncingRef.current = false;
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setFetched(true);
|
||||
setSaveDisabled(true);
|
||||
}, []);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
||||
xraySetting: xraySettingRef.current,
|
||||
outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204',
|
||||
});
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [fetchAll]);
|
||||
|
||||
const fetchOutboundsTraffic = useCallback(async () => {
|
||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
|
||||
if (msg?.success) setOutboundsTraffic(msg.obj || []);
|
||||
}, []);
|
||||
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => {
|
||||
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
|
||||
if (msg?.success) await fetchOutboundsTraffic();
|
||||
}, [fetchOutboundsTraffic]);
|
||||
|
||||
const applyOutboundsEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]);
|
||||
}, []);
|
||||
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound) return null;
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: true, result: null, mode },
|
||||
}));
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||
outbound: JSON.stringify(outbound),
|
||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||
mode,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: false, result: msg.obj },
|
||||
}));
|
||||
return msg.obj;
|
||||
}
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
testing: false,
|
||||
result: { success: false, error: msg?.msg || 'Unknown error', mode },
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
testing: false,
|
||||
result: { success: false, error: String(e), mode },
|
||||
},
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||
const list = templateSettingsRef.current?.outbounds || [];
|
||||
if (list.length === 0 || testingAll) return;
|
||||
setTestingAll(true);
|
||||
try {
|
||||
const concurrency = mode === 'tcp' ? 8 : 1;
|
||||
const queue = list
|
||||
.map((ob, i) => ({ index: i, outbound: ob }))
|
||||
.filter(({ outbound }) => {
|
||||
const tag = outbound?.tag;
|
||||
const proto = outbound?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
|
||||
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
|
||||
return true;
|
||||
});
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
await testOutbound(item.index, item.outbound, mode);
|
||||
}
|
||||
}
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, queue.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
}, [testingAll, testOutbound]);
|
||||
|
||||
const resetToDefault = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||
if (msg?.success) {
|
||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
||||
setTemplateSettings(cloned);
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [setTemplateSettings]);
|
||||
|
||||
const restartXray = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
if (msg?.success) {
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
||||
if (r?.success) setRestartResult(r.obj || '');
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
fetchOutboundsTraffic();
|
||||
const timer = window.setInterval(() => {
|
||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
||||
}, DIRTY_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fetchAll, fetchOutboundsTraffic]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
fetched,
|
||||
spinning,
|
||||
saveDisabled,
|
||||
fetchError,
|
||||
xraySetting,
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
}),
|
||||
[
|
||||
fetched,
|
||||
spinning,
|
||||
saveDisabled,
|
||||
fetchError,
|
||||
xraySetting,
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
import enUS from '../../../web/translation/en-US.json';
|
||||
|
||||
const FALLBACK = 'en-US';
|
||||
const lazyModules = import.meta.glob([
|
||||
'../../../web/translation/*.json',
|
||||
'!../../../web/translation/en-US.json',
|
||||
]);
|
||||
|
||||
function moduleKeyFor(code) {
|
||||
return `../../../web/translation/${code}.json`;
|
||||
}
|
||||
|
||||
let active = LanguageManager.getLanguage();
|
||||
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: active,
|
||||
fallbackLocale: FALLBACK,
|
||||
messages: { [FALLBACK]: enUS },
|
||||
warnHtmlMessage: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
|
||||
export function t(key, params) {
|
||||
return i18n.global.t(key, params || {});
|
||||
}
|
||||
|
||||
export async function loadLocale(code) {
|
||||
if (code === FALLBACK) {
|
||||
i18n.global.locale.value = FALLBACK;
|
||||
return true;
|
||||
}
|
||||
const loader = lazyModules[moduleKeyFor(code)];
|
||||
if (!loader) return false;
|
||||
const mod = await loader();
|
||||
i18n.global.setLocaleMessage(code, mod.default || mod);
|
||||
i18n.global.locale.value = code;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function readyI18n() {
|
||||
if (active !== FALLBACK) {
|
||||
await loadLocale(active);
|
||||
}
|
||||
return i18n;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
import enUS from '../../../web/translation/en-US.json';
|
||||
|
||||
const FALLBACK = 'en-US';
|
||||
|
||||
const lazyModules = import.meta.glob([
|
||||
'../../../web/translation/*.json',
|
||||
'!../../../web/translation/en-US.json',
|
||||
]);
|
||||
|
||||
function moduleKeyFor(code: string): string {
|
||||
return `../../../web/translation/${code}.json`;
|
||||
}
|
||||
|
||||
let active: string = LanguageManager.getLanguage();
|
||||
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
export async function readyI18n() {
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: active,
|
||||
fallbackLng: FALLBACK,
|
||||
resources: { [FALLBACK]: { translation: enUS } },
|
||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||
returnNull: false,
|
||||
});
|
||||
if (active !== FALLBACK) {
|
||||
const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record<string, unknown> }>) | undefined;
|
||||
if (loader) {
|
||||
const mod = await loader();
|
||||
const messages = (mod.default ?? mod) as Record<string, unknown>;
|
||||
i18next.addResourceBundle(active, 'translation', messages, true, true);
|
||||
await i18next.changeLanguage(active);
|
||||
}
|
||||
}
|
||||
return i18next;
|
||||
}
|
||||
|
||||
export { i18next as i18n };
|
||||
@@ -1,108 +0,0 @@
|
||||
// Mirrors web/assets/js/model/setting.js — every field on this class is
|
||||
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
|
||||
// so adding a field here without a matching Go-side change will silently
|
||||
// drop it on save. Defaults match the legacy panel.
|
||||
|
||||
import { ObjectUtil } from '@/utils';
|
||||
|
||||
export class AllSetting {
|
||||
|
||||
constructor(data) {
|
||||
this.webListen = "";
|
||||
this.webDomain = "";
|
||||
this.webPort = 2053;
|
||||
this.webCertFile = "";
|
||||
this.webKeyFile = "";
|
||||
this.webBasePath = "/";
|
||||
this.sessionMaxAge = 360;
|
||||
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
|
||||
this.pageSize = 25;
|
||||
this.expireDiff = 0;
|
||||
this.trafficDiff = 0;
|
||||
this.remarkModel = "-ieo";
|
||||
this.datepicker = "gregorian";
|
||||
this.tgBotEnable = false;
|
||||
this.tgBotToken = "";
|
||||
this.tgBotProxy = "";
|
||||
this.tgBotAPIServer = "";
|
||||
this.tgBotChatId = "";
|
||||
this.tgRunTime = "@daily";
|
||||
this.tgBotBackup = false;
|
||||
this.tgBotLoginNotify = true;
|
||||
this.tgCpu = 80;
|
||||
this.tgLang = "en-US";
|
||||
this.twoFactorEnable = false;
|
||||
this.twoFactorToken = "";
|
||||
this.xrayTemplateConfig = "";
|
||||
this.subEnable = true;
|
||||
this.subJsonEnable = false;
|
||||
this.subTitle = "";
|
||||
this.subSupportUrl = "";
|
||||
this.subProfileUrl = "";
|
||||
this.subAnnounce = "";
|
||||
this.subEnableRouting = true;
|
||||
this.subRoutingRules = "";
|
||||
this.subListen = "";
|
||||
this.subPort = 2096;
|
||||
this.subPath = "/sub/";
|
||||
this.subJsonPath = "/json/";
|
||||
this.subClashEnable = true;
|
||||
this.subClashPath = "/clash/";
|
||||
this.subDomain = "";
|
||||
this.externalTrafficInformEnable = false;
|
||||
this.externalTrafficInformURI = "";
|
||||
this.restartXrayOnClientDisable = true;
|
||||
this.subCertFile = "";
|
||||
this.subKeyFile = "";
|
||||
this.subUpdates = 12;
|
||||
this.subEncrypt = true;
|
||||
this.subShowInfo = true;
|
||||
this.subEmailInRemark = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
this.subJsonFragment = "";
|
||||
this.subJsonNoises = "";
|
||||
this.subJsonMux = "";
|
||||
this.subJsonRules = "";
|
||||
|
||||
this.timeLocation = "Local";
|
||||
|
||||
// LDAP settings
|
||||
this.ldapEnable = false;
|
||||
this.ldapHost = "";
|
||||
this.ldapPort = 389;
|
||||
this.ldapUseTLS = false;
|
||||
this.ldapBindDN = "";
|
||||
this.ldapPassword = "";
|
||||
this.ldapBaseDN = "";
|
||||
this.ldapUserFilter = "(objectClass=person)";
|
||||
this.ldapUserAttr = "mail";
|
||||
this.ldapVlessField = "vless_enabled";
|
||||
this.ldapSyncCron = "@every 1m";
|
||||
this.ldapFlagField = "";
|
||||
this.ldapTruthyValues = "true,1,yes,on";
|
||||
this.ldapInvertFlag = false;
|
||||
this.ldapInboundTags = "";
|
||||
this.ldapAutoCreate = false;
|
||||
this.ldapAutoDelete = false;
|
||||
this.ldapDefaultTotalGB = 0;
|
||||
this.ldapDefaultExpiryDays = 0;
|
||||
this.ldapDefaultLimitIP = 0;
|
||||
this.hasTgBotToken = false;
|
||||
this.hasTwoFactorToken = false;
|
||||
this.hasLdapPassword = false;
|
||||
this.hasApiToken = false;
|
||||
this.hasWarpSecret = false;
|
||||
this.hasNordSecret = false;
|
||||
|
||||
if (data == null) {
|
||||
return
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ObjectUtil } from '@/utils';
|
||||
|
||||
export class AllSetting {
|
||||
webListen = '';
|
||||
webDomain = '';
|
||||
webPort = 2053;
|
||||
webCertFile = '';
|
||||
webKeyFile = '';
|
||||
webBasePath = '/';
|
||||
sessionMaxAge = 360;
|
||||
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
||||
pageSize = 25;
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkModel = '-ieo';
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
tgBotProxy = '';
|
||||
tgBotAPIServer = '';
|
||||
tgBotChatId = '';
|
||||
tgRunTime = '@daily';
|
||||
tgBotBackup = false;
|
||||
tgBotLoginNotify = true;
|
||||
tgCpu = 80;
|
||||
tgLang = 'en-US';
|
||||
twoFactorEnable = false;
|
||||
twoFactorToken = '';
|
||||
xrayTemplateConfig = '';
|
||||
subEnable = true;
|
||||
subJsonEnable = false;
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileUrl = '';
|
||||
subAnnounce = '';
|
||||
subEnableRouting = true;
|
||||
subRoutingRules = '';
|
||||
subListen = '';
|
||||
subPort = 2096;
|
||||
subPath = '/sub/';
|
||||
subJsonPath = '/json/';
|
||||
subClashEnable = true;
|
||||
subClashPath = '/clash/';
|
||||
subDomain = '';
|
||||
externalTrafficInformEnable = false;
|
||||
externalTrafficInformURI = '';
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
subUpdates = 12;
|
||||
subEncrypt = true;
|
||||
subShowInfo = true;
|
||||
subEmailInRemark = true;
|
||||
subURI = '';
|
||||
subJsonURI = '';
|
||||
subClashURI = '';
|
||||
subJsonFragment = '';
|
||||
subJsonNoises = '';
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
ldapEnable = false;
|
||||
ldapHost = '';
|
||||
ldapPort = 389;
|
||||
ldapUseTLS = false;
|
||||
ldapBindDN = '';
|
||||
ldapPassword = '';
|
||||
ldapBaseDN = '';
|
||||
ldapUserFilter = '(objectClass=person)';
|
||||
ldapUserAttr = 'mail';
|
||||
ldapVlessField = 'vless_enabled';
|
||||
ldapSyncCron = '@every 1m';
|
||||
ldapFlagField = '';
|
||||
ldapTruthyValues = 'true,1,yes,on';
|
||||
ldapInvertFlag = false;
|
||||
ldapInboundTags = '';
|
||||
ldapAutoCreate = false;
|
||||
ldapAutoDelete = false;
|
||||
ldapDefaultTotalGB = 0;
|
||||
ldapDefaultExpiryDays = 0;
|
||||
ldapDefaultLimitIP = 0;
|
||||
hasTgBotToken = false;
|
||||
hasTwoFactorToken = false;
|
||||
hasLdapPassword = false;
|
||||
hasApiToken = false;
|
||||
hasWarpSecret = false;
|
||||
hasNordSecret = false;
|
||||
|
||||
constructor(data?: unknown) {
|
||||
if (data != null) {
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
equals(other: AllSetting): boolean {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
|
||||
export class CurTotal {
|
||||
constructor(current, total) {
|
||||
this.current = current;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
get percent() {
|
||||
if (this.total === 0) return 0;
|
||||
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
|
||||
}
|
||||
|
||||
get color() {
|
||||
// Match AD-Vue 4's semantic palette so the gauges fit the
|
||||
// global blue/gold/red theme instead of the legacy teal/orange.
|
||||
const p = this.percent;
|
||||
if (p < 80) return '#1677ff'; // primary
|
||||
if (p < 90) return '#faad14'; // warning
|
||||
return '#ff4d4f'; // danger
|
||||
}
|
||||
}
|
||||
|
||||
const XRAY_STATE_COLORS = {
|
||||
running: 'green',
|
||||
stop: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
export class Status {
|
||||
constructor(data) {
|
||||
this.cpu = new CurTotal(0, 0);
|
||||
this.cpuCores = 0;
|
||||
this.logicalPro = 0;
|
||||
this.cpuSpeedMhz = 0;
|
||||
this.disk = new CurTotal(0, 0);
|
||||
this.loads = [0, 0, 0];
|
||||
this.mem = new CurTotal(0, 0);
|
||||
this.netIO = { up: 0, down: 0 };
|
||||
this.netTraffic = { sent: 0, recv: 0 };
|
||||
this.publicIP = { ipv4: 0, ipv6: 0 };
|
||||
this.swap = new CurTotal(0, 0);
|
||||
this.tcpCount = 0;
|
||||
this.udpCount = 0;
|
||||
this.uptime = 0;
|
||||
this.appUptime = 0;
|
||||
this.appStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
this.xray = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
this.cpu = new CurTotal(data.cpu, 100);
|
||||
this.cpuCores = data.cpuCores;
|
||||
this.logicalPro = data.logicalPro;
|
||||
this.cpuSpeedMhz = data.cpuSpeedMhz;
|
||||
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
|
||||
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
|
||||
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
|
||||
this.netIO = data.netIO ?? this.netIO;
|
||||
this.netTraffic = data.netTraffic ?? this.netTraffic;
|
||||
this.publicIP = data.publicIP ?? this.publicIP;
|
||||
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
|
||||
this.tcpCount = data.tcpCount ?? 0;
|
||||
this.udpCount = data.udpCount ?? 0;
|
||||
this.uptime = data.uptime ?? 0;
|
||||
this.appUptime = data.appUptime ?? 0;
|
||||
this.appStats = data.appStats ?? this.appStats;
|
||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
|
||||
export class CurTotal {
|
||||
current: number;
|
||||
total: number;
|
||||
|
||||
constructor(current: number, total: number) {
|
||||
this.current = current;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
get percent(): number {
|
||||
if (this.total === 0) return 0;
|
||||
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
|
||||
}
|
||||
|
||||
get color(): string {
|
||||
const p = this.percent;
|
||||
if (p < 80) return '#1677ff';
|
||||
if (p < 90) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
}
|
||||
}
|
||||
|
||||
const XRAY_STATE_COLORS: Record<string, string> = {
|
||||
running: 'green',
|
||||
stop: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
export interface NetIO {
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export interface NetTraffic {
|
||||
sent: number;
|
||||
recv: number;
|
||||
}
|
||||
|
||||
export interface PublicIP {
|
||||
ipv4: string | number;
|
||||
ipv6: string | number;
|
||||
}
|
||||
|
||||
export interface AppStats {
|
||||
threads: number;
|
||||
mem: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface XrayInfo {
|
||||
state: 'running' | 'stop' | 'error' | string;
|
||||
errorMsg: string;
|
||||
version: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusInput {
|
||||
cpu?: number;
|
||||
cpuCores?: number;
|
||||
logicalPro?: number;
|
||||
cpuSpeedMhz?: number;
|
||||
disk?: { current?: number; total?: number };
|
||||
loads?: number[];
|
||||
mem?: { current?: number; total?: number };
|
||||
netIO?: NetIO;
|
||||
netTraffic?: NetTraffic;
|
||||
publicIP?: PublicIP;
|
||||
swap?: { current?: number; total?: number };
|
||||
tcpCount?: number;
|
||||
udpCount?: number;
|
||||
uptime?: number;
|
||||
appUptime?: number;
|
||||
appStats?: AppStats;
|
||||
xray?: Partial<XrayInfo>;
|
||||
}
|
||||
|
||||
export class Status {
|
||||
cpu: CurTotal = new CurTotal(0, 0);
|
||||
cpuCores = 0;
|
||||
logicalPro = 0;
|
||||
cpuSpeedMhz = 0;
|
||||
disk: CurTotal = new CurTotal(0, 0);
|
||||
loads: number[] = [0, 0, 0];
|
||||
mem: CurTotal = new CurTotal(0, 0);
|
||||
netIO: NetIO = { up: 0, down: 0 };
|
||||
netTraffic: NetTraffic = { sent: 0, recv: 0 };
|
||||
publicIP: PublicIP = { ipv4: 0, ipv6: 0 };
|
||||
swap: CurTotal = new CurTotal(0, 0);
|
||||
tcpCount = 0;
|
||||
udpCount = 0;
|
||||
uptime = 0;
|
||||
appUptime = 0;
|
||||
appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||
|
||||
constructor(data?: StatusInput | null) {
|
||||
if (data == null) return;
|
||||
|
||||
this.cpu = new CurTotal(data.cpu ?? 0, 100);
|
||||
this.cpuCores = data.cpuCores ?? 0;
|
||||
this.logicalPro = data.logicalPro ?? 0;
|
||||
this.cpuSpeedMhz = data.cpuSpeedMhz ?? 0;
|
||||
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
|
||||
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
|
||||
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
|
||||
this.netIO = data.netIO ?? this.netIO;
|
||||
this.netTraffic = data.netTraffic ?? this.netTraffic;
|
||||
this.publicIP = data.publicIP ?? this.publicIP;
|
||||
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
|
||||
this.tcpCount = data.tcpCount ?? 0;
|
||||
this.udpCount = data.udpCount ?? 0;
|
||||
this.uptime = data.uptime ?? 0;
|
||||
this.appUptime = data.appUptime ?? 0;
|
||||
this.appStats = data.appStats ?? this.appStats;
|
||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||
}
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user