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>