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:
Sanaei
2026-05-23 15:21:45 +02:00
committed by GitHub
parent 237b7c898d
commit edf0f36940
269 changed files with 30242 additions and 25727 deletions
+287
View File
@@ -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;
}
+290
View File
@@ -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>
);
}
-432
View File
@@ -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%' }}
/>
);
}
-366
View File
@@ -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>
+738
View File
@@ -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>
))}
</>
);
}
-510
View File
@@ -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>
+19
View File
@@ -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>
);
}
-18
View File
@@ -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>
+40
View File
@@ -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;
}
+21
View File
@@ -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>
);
}
+26
View File
@@ -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;
}
+179
View File
@@ -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;
-185
View File
@@ -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>
+82
View File
@@ -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>
);
}
-52
View File
@@ -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>
+44
View File
@@ -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));
}
+368
View File
@@ -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>
);
}
-297
View File
@@ -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>
-311
View File
@@ -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>
+59
View File
@@ -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>
</>
);
}
-66
View File
@@ -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>
-45
View File
@@ -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) };
}
-26
View File
@@ -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 };
}
-44
View File
@@ -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 };
}
-43
View File
@@ -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 };
}
-128
View File
@@ -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';
});
-48
View File
@@ -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 };
}
-21
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
-21
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
-21
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
-23
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
-23
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
-21
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
-23
View File
@@ -1,23 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import SettingsPage from '@/pages/settings/SettingsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<SettingsPage />
</ThemeProvider>,
);
}
});
-20
View File
@@ -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');
});
+23
View File
@@ -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>,
);
}
});
-21
View File
@@ -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');
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import 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>,
);
}
});
+65
View File
@@ -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>>;
}
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import { AllSetting } from '@/models/setting';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
export function useAllSetting() {
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
const [fetched, setFetched] = useState(false);
const [spinning, setSpinning] = useState(false);
const fetchedRef = useRef(false);
const applyServerState = useCallback((obj: unknown) => {
setAllSetting(new AllSetting(obj));
setOldAllSetting(new AllSetting(obj));
}, []);
const fetchAll = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
if (msg?.success) {
applyServerState(msg.obj);
fetchedRef.current = true;
setFetched(true);
}
}, [applyServerState]);
const saveAll = useCallback(async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
if (msg?.success) await fetchAll();
} finally {
setSpinning(false);
}
}, [allSetting, fetchAll]);
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setAllSetting((prev) => {
const next = new AllSetting(prev);
Object.assign(next, patch);
return next;
});
}, []);
const saveDisabled = useMemo(
() => allSetting.equals(oldAllSetting),
[allSetting, oldAllSetting],
);
useEffect(() => {
fetchAll();
}, [fetchAll]);
return {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
fetchAll,
saveAll,
};
}
+282
View File
@@ -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,
};
}
+57
View File
@@ -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 };
}
+15
View File
@@ -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 };
}
+177
View File
@@ -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,
};
}
+35
View File
@@ -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 };
}
+136
View File
@@ -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;
}
+32
View File
@@ -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
}, []);
}
+370
View File
@@ -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,
],
);
}
-54
View File
@@ -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;
}
+43
View File
@@ -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 };
-108
View File
@@ -1,108 +0,0 @@
// Mirrors web/assets/js/model/setting.js — every field on this class is
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
// so adding a field here without a matching Go-side change will silently
// drop it on save. Defaults match the legacy panel.
import { ObjectUtil } from '@/utils';
export class AllSetting {
constructor(data) {
this.webListen = "";
this.webDomain = "";
this.webPort = 2053;
this.webCertFile = "";
this.webKeyFile = "";
this.webBasePath = "/";
this.sessionMaxAge = 360;
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
this.pageSize = 25;
this.expireDiff = 0;
this.trafficDiff = 0;
this.remarkModel = "-ieo";
this.datepicker = "gregorian";
this.tgBotEnable = false;
this.tgBotToken = "";
this.tgBotProxy = "";
this.tgBotAPIServer = "";
this.tgBotChatId = "";
this.tgRunTime = "@daily";
this.tgBotBackup = false;
this.tgBotLoginNotify = true;
this.tgCpu = 80;
this.tgLang = "en-US";
this.twoFactorEnable = false;
this.twoFactorToken = "";
this.xrayTemplateConfig = "";
this.subEnable = true;
this.subJsonEnable = false;
this.subTitle = "";
this.subSupportUrl = "";
this.subProfileUrl = "";
this.subAnnounce = "";
this.subEnableRouting = true;
this.subRoutingRules = "";
this.subListen = "";
this.subPort = 2096;
this.subPath = "/sub/";
this.subJsonPath = "/json/";
this.subClashEnable = true;
this.subClashPath = "/clash/";
this.subDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
this.restartXrayOnClientDisable = true;
this.subCertFile = "";
this.subKeyFile = "";
this.subUpdates = 12;
this.subEncrypt = true;
this.subShowInfo = true;
this.subEmailInRemark = true;
this.subURI = "";
this.subJsonURI = "";
this.subClashURI = "";
this.subJsonFragment = "";
this.subJsonNoises = "";
this.subJsonMux = "";
this.subJsonRules = "";
this.timeLocation = "Local";
// LDAP settings
this.ldapEnable = false;
this.ldapHost = "";
this.ldapPort = 389;
this.ldapUseTLS = false;
this.ldapBindDN = "";
this.ldapPassword = "";
this.ldapBaseDN = "";
this.ldapUserFilter = "(objectClass=person)";
this.ldapUserAttr = "mail";
this.ldapVlessField = "vless_enabled";
this.ldapSyncCron = "@every 1m";
this.ldapFlagField = "";
this.ldapTruthyValues = "true,1,yes,on";
this.ldapInvertFlag = false;
this.ldapInboundTags = "";
this.ldapAutoCreate = false;
this.ldapAutoDelete = false;
this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0;
this.hasTgBotToken = false;
this.hasTwoFactorToken = false;
this.hasLdapPassword = false;
this.hasApiToken = false;
this.hasWarpSecret = false;
this.hasNordSecret = false;
if (data == null) {
return
}
ObjectUtil.cloneProps(this, data);
}
equals(other) {
return ObjectUtil.equals(this, other);
}
}
+100
View File
@@ -0,0 +1,100 @@
import { ObjectUtil } from '@/utils';
export class AllSetting {
webListen = '';
webDomain = '';
webPort = 2053;
webCertFile = '';
webKeyFile = '';
webBasePath = '/';
sessionMaxAge = 360;
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
remarkModel = '-ieo';
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
tgBotProxy = '';
tgBotAPIServer = '';
tgBotChatId = '';
tgRunTime = '@daily';
tgBotBackup = false;
tgBotLoginNotify = true;
tgCpu = 80;
tgLang = 'en-US';
twoFactorEnable = false;
twoFactorToken = '';
xrayTemplateConfig = '';
subEnable = true;
subJsonEnable = false;
subTitle = '';
subSupportUrl = '';
subProfileUrl = '';
subAnnounce = '';
subEnableRouting = true;
subRoutingRules = '';
subListen = '';
subPort = 2096;
subPath = '/sub/';
subJsonPath = '/json/';
subClashEnable = true;
subClashPath = '/clash/';
subDomain = '';
externalTrafficInformEnable = false;
externalTrafficInformURI = '';
restartXrayOnClientDisable = true;
subCertFile = '';
subKeyFile = '';
subUpdates = 12;
subEncrypt = true;
subShowInfo = true;
subEmailInRemark = true;
subURI = '';
subJsonURI = '';
subClashURI = '';
subJsonFragment = '';
subJsonNoises = '';
subJsonMux = '';
subJsonRules = '';
timeLocation = 'Local';
ldapEnable = false;
ldapHost = '';
ldapPort = 389;
ldapUseTLS = false;
ldapBindDN = '';
ldapPassword = '';
ldapBaseDN = '';
ldapUserFilter = '(objectClass=person)';
ldapUserAttr = 'mail';
ldapVlessField = 'vless_enabled';
ldapSyncCron = '@every 1m';
ldapFlagField = '';
ldapTruthyValues = 'true,1,yes,on';
ldapInvertFlag = false;
ldapInboundTags = '';
ldapAutoCreate = false;
ldapAutoDelete = false;
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
hasTgBotToken = false;
hasTwoFactorToken = false;
hasLdapPassword = false;
hasApiToken = false;
hasWarpSecret = false;
hasNordSecret = false;
constructor(data?: unknown) {
if (data != null) {
ObjectUtil.cloneProps(this, data);
}
}
equals(other: AllSetting): boolean {
return ObjectUtil.equals(this, other);
}
}
-71
View File
@@ -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';
}
}
+120
View File
@@ -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';
}
}
+292
View File
@@ -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;
}
+247
View File
@@ -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 &lt;token&gt;</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 &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don&apos;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>
);
}
-561
View File
@@ -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 &lt;token&gt;</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 &lt;token&gt;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>
+69
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>
);
}
-172
View File
@@ -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>
@@ -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>
+221
View File
@@ -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;
}
+908
View File
@@ -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
-220
View File
@@ -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