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
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, Modal, Select } from 'antd';
export interface BalancerFormValue {
tag: string;
strategy: string;
selector: string[];
fallbackTag: string;
}
interface BalancerFormModalProps {
open: boolean;
balancer: BalancerFormValue | null;
outboundTags: string[];
otherTags: string[];
onClose: () => void;
onConfirm: (value: BalancerFormValue) => void;
}
const STRATEGIES = [
{ value: 'random', label: 'Random' },
{ value: 'roundRobin', label: 'Round robin' },
{ value: 'leastLoad', label: 'Least load' },
{ value: 'leastPing', label: 'Least ping' },
];
export default function BalancerFormModal({
open,
balancer,
outboundTags,
otherTags,
onClose,
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const [tag, setTag] = useState(() => balancer?.tag || '');
const [strategy, setStrategy] = useState(() => balancer?.strategy || 'random');
const [selector, setSelector] = useState<string[]>(() => [...(balancer?.selector || [])]);
const [fallbackTag, setFallbackTag] = useState(() => balancer?.fallbackTag || '');
const isEdit = balancer != null;
useEffect(() => {
if (!open) return;
if (balancer) {
setTag(balancer.tag || '');
setStrategy(balancer.strategy || 'random');
setSelector([...(balancer.selector || [])]);
setFallbackTag(balancer.fallbackTag || '');
} else {
setTag('');
setStrategy('random');
setSelector([]);
setFallbackTag('');
}
}, [open, balancer]);
const tagEmpty = !tag.trim();
const duplicateTag = !!tag && otherTags.includes(tag.trim());
const emptySelector = selector.length === 0;
const isValid = !tagEmpty && !duplicateTag && !emptySelector;
const tagValidateStatus: 'error' | 'warning' | 'success' = tagEmpty
? 'error'
: duplicateTag
? 'warning'
: 'success';
const tagHelp = tagEmpty
? 'Tag is required'
: duplicateTag
? 'Tag already used by another balancer'
: '';
const selectorValidateStatus: 'error' | 'success' = emptySelector ? 'error' : 'success';
const selectorHelp = emptySelector ? 'Pick at least one outbound' : '';
function submit() {
if (!isValid) return;
onConfirm({ tag, strategy, selector, fallbackTag });
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
const fallbackOptions = useMemo(
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
[outboundTags, t],
);
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
okButtonProps={{ disabled: !isValid }}
mask={{ closable: false }}
destroyOnHidden
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label="Tag" validateStatus={tagValidateStatus} help={tagHelp} hasFeedback>
<Input value={tag} onChange={(e) => setTag(e.target.value)} placeholder="unique balancer tag" />
</Form.Item>
<Form.Item label="Strategy">
<Select value={strategy} onChange={setStrategy} options={STRATEGIES} />
</Form.Item>
<Form.Item
label="Selector"
validateStatus={selectorValidateStatus}
help={selectorHelp}
hasFeedback
>
<Select
mode="tags"
value={selector}
onChange={setSelector}
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</Form.Item>
<Form.Item label="Fallback">
<Select value={fallbackTag} onChange={setFallbackTag} allowClear options={fallbackOptions} />
</Form.Item>
</Form>
</Modal>
);
}
@@ -1,123 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Balancer add/edit modal — mirrors xray_balancer_modal.html.
// Tag must be unique across other balancers; selector is a tag-mode
// list constrained to existing outbound tags (but lets users type
// new ones for forward-references).
const props = defineProps({
open: { type: Boolean, default: false },
balancer: { type: Object, default: null },
outboundTags: { type: Array, default: () => [] },
// All other balancer tags (excludes the one currently being edited)
// — used for the duplicate-tag check.
otherTags: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:open', 'confirm']);
const STRATEGIES = [
{ value: 'random', label: 'Random' },
{ value: 'roundRobin', label: 'Round robin' },
{ value: 'leastLoad', label: 'Least load' },
{ value: 'leastPing', label: 'Least ping' },
];
const form = reactive({
tag: '',
strategy: 'random',
selector: [],
fallbackTag: '',
});
const isEdit = ref(false);
watch(() => props.open, (next) => {
if (!next) return;
if (props.balancer) {
isEdit.value = true;
form.tag = props.balancer.tag || '';
form.strategy = props.balancer.strategy || 'random';
form.selector = [...(props.balancer.selector || [])];
form.fallbackTag = props.balancer.fallbackTag || '';
} else {
isEdit.value = false;
form.tag = '';
form.strategy = 'random';
form.selector = [];
form.fallbackTag = '';
}
});
const tagEmpty = computed(() => !form.tag?.trim());
const duplicateTag = computed(
() => !!form.tag && props.otherTags.includes(form.tag.trim()),
);
const emptySelector = computed(() => form.selector.length === 0);
const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
);
const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning';
return 'success';
});
const tagHelp = computed(() => {
if (tagEmpty.value) return 'Tag is required';
if (duplicateTag.value) return 'Tag already used by another balancer';
return '';
});
const selectorValidateStatus = computed(() => (emptySelector.value ? 'error' : 'success'));
const selectorHelp = computed(() => (emptySelector.value ? 'Pick at least one outbound' : ''));
function close() { emit('update:open', false); }
function onOk() {
if (!isValid.value) return;
emit('confirm', { ...form });
}
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
);
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')"
:ok-button-props="{ disabled: !isValid }" :mask-closable="false" @ok="onOk" @cancel="close">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item label="Tag" :validate-status="tagValidateStatus" :help="tagHelp" has-feedback>
<a-input v-model:value="form.tag" placeholder="unique balancer tag" />
</a-form-item>
<a-form-item label="Strategy">
<a-select v-model:value="form.strategy">
<a-select-option v-for="s in STRATEGIES" :key="s.value" :value="s.value">{{ s.label }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Selector" :validate-status="selectorValidateStatus" :help="selectorHelp" has-feedback>
<a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
<a-select-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Fallback">
<a-select v-model:value="form.fallbackTag" allow-clear>
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</template>
+366
View File
@@ -0,0 +1,366 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Dropdown, Empty, Modal, Radio, Space, Table, Tag } from 'antd';
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
import JsonEditor from '@/components/JsonEditor';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
clientReverseTags: string[];
isMobile: boolean;
}
interface BalancerRecord {
tag: string;
selector?: string[];
fallbackTag?: string;
strategy?: { type?: string };
}
interface BalancerRow {
key: number;
tag: string;
strategy: string;
selector: string[];
fallbackTag: string;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
const DEFAULT_OBSERVATORY = Object.freeze({
subjectSelector: [] as string[],
probeURL: 'https://www.google.com/generate_204',
probeInterval: '1m',
enableConcurrency: true,
});
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
subjectSelector: [] as string[],
pingConfig: {
destination: 'https://www.google.com/generate_204',
interval: '1m',
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
timeout: '5s',
sampling: 2,
},
});
function collectSelectors(list: BalancerRecord[]): string[] {
const out = new Set<string>();
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
return [...out];
}
function syncObservatories(t: XraySettingsValue) {
const balancers = (t.routing?.balancers || []) as BalancerRecord[];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
if (leastPings.length > 0) {
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
(t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
} else {
delete t.observatory;
}
const burstFeeders = balancers.filter((b) => {
const type = b.strategy?.type || 'random';
return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
});
if (burstFeeders.length > 0) {
if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(burstFeeders);
} else {
delete t.burstObservatory;
}
}
export default function BalancersTab({
templateSettings,
setTemplateSettings,
clientReverseTags,
isMobile,
}: BalancersTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [modalOpen, setModalOpen] = useState(false);
const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const rows: BalancerRow[] = useMemo(() => {
const list = (templateSettings?.routing?.balancers || []) as BalancerRecord[];
return list.map((b, idx) => ({
key: idx,
tag: b.tag || '',
strategy: b.strategy?.type || 'random',
selector: b.selector || [],
fallbackTag: b.fallbackTag || '',
}));
}, [templateSettings?.routing?.balancers]);
const outboundTags = useMemo(() => {
const tags = new Set<string>();
for (const o of templateSettings?.outbounds || []) {
if (o?.tag) tags.add(o.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, clientReverseTags]);
const otherTags = useMemo(() => {
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean);
}, [rows, editingIndex]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function openAdd() {
setEditingBalancer(null);
setEditingIndex(null);
setModalOpen(true);
}
function openEdit(idx: number) {
setEditingBalancer(rows[idx]);
setEditingIndex(idx);
setModalOpen(true);
}
function onConfirm(form: BalancerFormValue) {
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [], balancers: [] };
if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
const list = tt.routing.balancers as BalancerRecord[];
const wire: BalancerRecord = {
tag: form.tag,
selector: [...form.selector],
fallbackTag: form.fallbackTag || '',
};
if (form.strategy && form.strategy !== 'random') {
wire.strategy = { type: form.strategy };
}
if (editingIndex == null) {
list.push(wire);
} else {
const oldTag = list[editingIndex]?.tag;
list[editingIndex] = wire;
if (oldTag && oldTag !== wire.tag) {
const rules = tt.routing.rules || [];
for (const rule of rules) {
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
}
}
}
syncObservatories(tt);
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => mutate((tt) => {
if (tt.routing?.balancers) {
tt.routing.balancers.splice(idx, 1);
syncObservatories(tt);
}
}),
});
}
const columns: ColumnsType<BalancerRow> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{
title: 'Strategy',
key: 'strategy',
align: 'center',
width: 140,
render: (_v, record) => (
<Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
{STRATEGY_LABELS[record.strategy] || record.strategy}
</Tag>
),
},
{
title: 'Selector',
key: 'selector',
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag">
{sel}
</Tag>
)),
},
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, isMobile],
);
const hasObservatory = !!templateSettings?.observatory;
const hasBurstObservatory = !!templateSettings?.burstObservatory;
const showObsEditor = hasObservatory || hasBurstObservatory;
const [obsView, setObsView] = useState<'observatory' | 'burstObservatory'>('observatory');
useEffect(() => {
if (obsView === 'observatory' && !hasObservatory && hasBurstObservatory) {
setObsView('burstObservatory');
} else if (obsView === 'burstObservatory' && !hasBurstObservatory && hasObservatory) {
setObsView('observatory');
}
}, [obsView, hasObservatory, hasBurstObservatory]);
const obsText = useMemo(() => {
const src = obsView === 'observatory' ? templateSettings?.observatory : templateSettings?.burstObservatory;
return src ? JSON.stringify(src, null, 2) : '';
}, [obsView, templateSettings?.observatory, templateSettings?.burstObservatory]);
function onObsTextChange(next: string) {
let parsed;
try {
parsed = JSON.parse(next);
} catch {
return;
}
mutate((tt) => {
if (obsView === 'observatory') tt.observatory = parsed;
else tt.burstObservatory = parsed;
});
}
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
{rows.length === 0 ? (
<Empty description={t('emptyBalancersDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
</Empty>
) : (
<>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
scroll={{ x: 400 }}
/>
{showObsEditor && (
<>
<Divider style={{ margin: '8px 0' }} />
<Radio.Group
value={obsView}
onChange={(e) => setObsView(e.target.value)}
optionType="button"
buttonStyle="solid"
size="small"
>
{hasObservatory && <Radio.Button value="observatory">Observatory</Radio.Button>}
{hasBurstObservatory && <Radio.Button value="burstObservatory">Burst Observatory</Radio.Button>}
</Radio.Group>
<JsonEditor
value={obsText}
onChange={onObsTextChange}
minHeight="220px"
maxHeight="480px"
/>
</>
)}
</>
)}
</Space>
<BalancerFormModal
open={modalOpen}
balancer={editingBalancer}
outboundTags={outboundTags}
otherTags={otherTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}
-362
View File
@@ -1,362 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import BalancerFormModal from './BalancerFormModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
const { t } = useI18n();
// Balancers tab — list + add/edit/delete over
// templateSettings.routing.balancers. The legacy panel kept the wire
// shape's `strategy: { type: 'random' }` nesting only when non-default;
// we follow the same convention on submit.
const props = defineProps({
templateSettings: { type: Object, default: null },
clientReverseTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false },
});
const STRATEGY_LABELS = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
// Observatory defaults — values that the legacy panel seeded when a
// leastPing balancer first appeared. ProbeURL / interval follow Xray's
// own docs (https://xtls.github.io/config/observatory.html).
const DEFAULT_OBSERVATORY = Object.freeze({
subjectSelector: [],
probeURL: 'https://www.google.com/generate_204',
probeInterval: '1m',
enableConcurrency: true,
});
// BurstObservatory defaults — seeded when a leastLoad balancer is
// configured. Hicloud's generate_204 is the same connectivity probe
// the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
subjectSelector: [],
pingConfig: {
destination: 'https://www.google.com/generate_204',
interval: '1m',
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
timeout: '5s',
sampling: 2,
},
});
const rows = computed(() => {
const list = props.templateSettings?.routing?.balancers || [];
return list.map((b, idx) => ({
key: idx,
tag: b.tag || '',
strategy: b.strategy?.type || 'random',
selector: b.selector || [],
fallbackTag: b.fallbackTag || '',
}));
});
const outboundTags = computed(() => {
const tags = new Set();
for (const o of props.templateSettings?.outbounds || []) {
if (o.tag) tags.add(o.tag);
}
for (const t of props.clientReverseTags || []) {
if (t) tags.add(t);
}
return [...tags];
});
// === Modal state ====================================================
const modalOpen = ref(false);
const editingBalancer = ref(null);
const editingIndex = ref(null);
const otherTags = ref([]);
function tagPool(excludeIdx) {
return rows.value.filter((b) => b.key !== excludeIdx).map((b) => b.tag).filter(Boolean);
}
function openAdd() {
editingBalancer.value = null;
editingIndex.value = null;
otherTags.value = rows.value.map((b) => b.tag).filter(Boolean);
modalOpen.value = true;
}
function openEdit(idx) {
editingBalancer.value = rows.value[idx];
editingIndex.value = idx;
otherTags.value = tagPool(idx);
modalOpen.value = true;
}
function ensureBalancersArray() {
if (!props.templateSettings.routing) return null;
if (!Array.isArray(props.templateSettings.routing.balancers)) {
props.templateSettings.routing.balancers = [];
}
return props.templateSettings.routing.balancers;
}
// Keep observatory / burstObservatory in sync with the configured
// balancers. leastPing balancers feed Observatory's subjectSelector;
// leastLoad balancers feed BurstObservatory's. When the matching
// strategy disappears we drop the observatory entirely so the rendered
// xray config stays minimal.
function collectSelectors(list) {
const out = new Set();
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
return [...out];
}
function syncObservatories() {
const t = props.templateSettings;
if (!t) return;
const balancers = t.routing?.balancers || [];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
if (leastPings.length > 0) {
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
t.observatory.subjectSelector = collectSelectors(leastPings);
} else {
delete t.observatory;
}
const burstFeeders = balancers.filter((b) => {
const type = b.strategy?.type || 'random';
return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
});
if (burstFeeders.length > 0) {
if (!t.burstObservatory) {
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
}
t.burstObservatory.subjectSelector = collectSelectors(burstFeeders);
} else {
delete t.burstObservatory;
}
}
function buildWireBalancer(form) {
const out = {
tag: form.tag,
selector: [...form.selector],
fallbackTag: form.fallbackTag || '',
};
if (form.strategy && form.strategy !== 'random') {
out.strategy = { type: form.strategy };
}
return out;
}
function onConfirm(form) {
const arr = ensureBalancersArray();
if (!arr) return;
const wire = buildWireBalancer(form);
if (editingIndex.value == null) {
arr.push(wire);
} else {
const oldTag = arr[editingIndex.value]?.tag;
arr[editingIndex.value] = wire;
// Preserve the legacy behaviour: when a balancer's tag is renamed,
// chase the rename across routing rules so existing references
// don't dangle.
if (oldTag && oldTag !== wire.tag) {
const rules = props.templateSettings.routing.rules || [];
for (const rule of rules) {
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
}
}
}
syncObservatories();
modalOpen.value = false;
}
function confirmDelete(idx) {
Modal.confirm({
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
// Wrap in a block so we discard splice's return value — AD-Vue
// 4 leaves the modal open if onOk returns a truthy non-thenable
// (it expects a Promise to await), and splice() returns the array
// of removed items.
onOk: () => {
props.templateSettings.routing.balancers.splice(idx, 1);
syncObservatories();
},
});
}
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 100 },
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
{ title: 'Selector', key: 'selector', align: 'center' },
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
]);
// === Observatory / BurstObservatory inline editor ====================
// The legacy panel surfaced both top-level observatory blocks here as a
// raw JSON editor so admins could tune probeURL / interval / sampling
// without having to drop into the full xray template tab. We keep that
// affordance but only render it when the matching observatory exists —
// which is itself driven by syncObservatories() above.
const hasObservatory = computed(() => !!props.templateSettings?.observatory);
const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
const obsView = ref('observatory');
// Watch each flag individually — watching showObsEditor (OR of the two)
// misses the case where one observatory swaps for the other in the same
// tick, leaving obsView pointing at a now-deleted key and JsonEditor
// trying to parse an empty string.
watch([hasObservatory, hasBurstObservatory], () => {
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
obsView.value = 'burstObservatory';
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
obsView.value = 'observatory';
}
}, { immediate: true });
const obsText = computed({
get: () => {
const t = props.templateSettings;
if (!t) return '';
const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
return src ? JSON.stringify(src, null, 2) : '';
},
set: (next) => {
let parsed;
try { parsed = JSON.parse(next); } catch (_e) { return; }
if (!props.templateSettings) return;
if (obsView.value === 'observatory') {
props.templateSettings.observatory = parsed;
} else {
props.templateSettings.burstObservatory = parsed;
}
},
});
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-empty v-if="rows.length === 0" :description="t('emptyBalancersDesc')">
<a-button type="primary" @click="openAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Balancers') }}
</a-button>
</a-empty>
<template v-else>
<a-button type="primary" @click="openAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Balancers') }}
</a-button>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
size="small" :scroll="{ x: 400 }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<div class="action-cell">
<span class="row-index">{{ index + 1 }}</span>
<div :class="!isMobile ? 'action-buttons' : ''">
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="isMobile" @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</template>
<template v-else-if="column.key === 'strategy'">
<a-tag :color="record.strategy === 'random' ? 'purple' : 'green'">
{{ STRATEGY_LABELS[record.strategy] || record.strategy }}
</a-tag>
</template>
<template v-else-if="column.key === 'selector'">
<a-tag v-for="sel in record.selector" :key="sel" class="info-large-tag">{{ sel }}</a-tag>
</template>
</template>
</a-table>
<template v-if="showObsEditor">
<a-divider :style="{ margin: '8px 0' }" />
<a-radio-group v-model:value="obsView" button-style="solid" size="small">
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
</a-radio-group>
<JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
</template>
</template>
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
:other-tags="otherTags" @confirm="onConfirm" />
</a-space>
</template>
<style scoped>
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.row-index {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.danger {
color: #ff4d4f;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
.mb-12 {
margin-bottom: 12px;
}
.hint-alert {
text-align: center;
}
+550
View File
@@ -0,0 +1,550 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/models/outbound.js';
import SettingListItem from '@/components/SettingListItem';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
onChangeOutboundTestUrl: (v: string) => void;
warpExist: boolean;
nordExist: boolean;
onShowWarp: () => void;
onShowNord: () => void;
onResetDefault: () => void;
}
const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
const ACCESS_LOG = ['none', './access.log'];
const ERROR_LOG = ['none', './error.log'];
const MASK_ADDRESS = ['quarter', 'half', 'full'];
const BITTORRENT_PROTOCOLS = ['bittorrent'];
const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const DOMAINS_OPTIONS = [
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const SERVICES_OPTIONS = [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
const directSettings = { tag: 'direct', protocol: 'freedom' };
const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
function ruleGetter(t: XraySettingsValue | null, outboundTag: string, property: string): string[] {
if (!t?.routing?.rules) return [];
const out: string[] = [];
for (const rule of t.routing.rules) {
if (
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag
) {
const v = (rule as Record<string, unknown>)[property];
if (Array.isArray(v)) out.push(...(v as string[]));
}
}
return out;
}
function ruleSetter(t: XraySettingsValue, outboundTag: string, property: string, data: string[]): void {
if (!t.routing) return;
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
const current = ruleGetter(t, outboundTag, property);
if (current.length === 0) {
t.routing.rules.push({ type: 'field', outboundTag, [property]: data });
return;
}
const next: typeof t.routing.rules = [];
let inserted = false;
for (const rule of t.routing.rules) {
const matches =
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag;
if (matches) {
if (!inserted && data.length > 0) {
(rule as Record<string, unknown>)[property] = data;
next.push(rule);
inserted = true;
}
} else {
next.push(rule);
}
}
t.routing.rules = next;
}
function syncOutbound(t: XraySettingsValue, tag: string, settings: Record<string, unknown>) {
if (!t.outbounds || !t.routing) return;
const rules = t.routing.rules || [];
const haveRules = rules.some((r) => r?.outboundTag === tag);
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
outboundTestUrl,
onChangeOutboundTestUrl,
warpExist,
nordExist,
onShowWarp,
onShowNord,
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function confirmResetDefault() {
modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => onResetDefault(),
});
}
const freedomStrategy =
(templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')?.settings as
| { domainStrategy?: string }
| undefined)?.domainStrategy ?? 'AsIs';
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
const blockedIPs = ruleGetter(templateSettings, 'blocked', 'ip');
const blockedDomains = ruleGetter(templateSettings, 'blocked', 'domain');
const blockedProtocols = ruleGetter(templateSettings, 'blocked', 'protocol');
const directIPs = ruleGetter(templateSettings, 'direct', 'ip');
const directDomains = ruleGetter(templateSettings, 'direct', 'domain');
const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
const warpDomains = ruleGetter(templateSettings, 'warp', 'domain');
const nordTag =
templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-'))?.tag || 'nord';
const nordDomains = ruleGetter(templateSettings, nordTag, 'domain');
const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
const items = [
{
key: '1',
label: t('pages.xray.generalConfigs'),
children: (
<>
<Alert
type="warning"
className="mb-12 hint-alert"
title={t('pages.xray.generalConfigsDesc')}
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
/>
<SettingListItem
title={t('pages.xray.FreedomStrategy')}
description={t('pages.xray.FreedomStrategyDesc')}
paddings="small"
control={
<Select
value={freedomStrategy}
style={{ width: '100%' }}
options={(OutboundDomainStrategies as string[]).map((s) => ({ value: s, label: s }))}
onChange={(next) => mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
const idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
if (idx < 0) {
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: { domainStrategy: next } });
} else {
const ob = tt.outbounds[idx];
ob.settings = (ob.settings || {}) as Record<string, unknown>;
(ob.settings as Record<string, unknown>).domainStrategy = next;
}
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
paddings="small"
control={
<Select
value={routingStrategy}
style={{ width: '100%' }}
options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(next) => mutate((tt) => {
if (tt.routing) tt.routing.domainStrategy = next;
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.outboundTestUrl')}
description={t('pages.xray.outboundTestUrlDesc')}
paddings="small"
control={
<Input
value={outboundTestUrl}
onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
placeholder="https://www.google.com/generate_204"
/>
}
/>
</>
),
},
{
key: '2',
label: t('pages.xray.statistics'),
children: (
<>
{[
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
['statsOutboundDownlink', 'Outbound downlink stats'],
].map(([field, label]) => (
<SettingListItem
key={field}
title={label}
paddings="small"
control={
<Switch
checked={!!policy[field]}
onChange={(checked) => mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.system) tt.policy.system = {};
tt.policy.system[field] = checked;
})}
/>
}
/>
))}
</>
),
},
{
key: '3',
label: t('pages.xray.logConfigs'),
children: (
<>
<Alert
type="warning"
className="mb-12 hint-alert"
title={t('pages.xray.logConfigsDesc')}
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
/>
<SettingListItem
title={t('pages.xray.logLevel')}
description={t('pages.xray.logLevelDesc')}
paddings="small"
control={
<Select
value={(log.loglevel as string) || 'warning'}
style={{ width: '100%' }}
options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
onChange={(v) => mutate((tt) => { if (tt.log) tt.log.loglevel = v; })}
/>
}
/>
<SettingListItem
title={t('pages.xray.accessLog')}
description={t('pages.xray.accessLogDesc')}
paddings="small"
control={
<Select
value={(log.access as string) || ''}
style={{ width: '100%' }}
options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
onChange={(v) => mutate((tt) => { if (tt.log) tt.log.access = v; })}
/>
}
/>
<SettingListItem
title={t('pages.xray.errorLog')}
description={t('pages.xray.errorLogDesc')}
paddings="small"
control={
<Select
value={(log.error as string) || ''}
style={{ width: '100%' }}
options={[{ value: '', label: t('empty') }, ...ERROR_LOG.map((s) => ({ value: s, label: s }))]}
onChange={(v) => mutate((tt) => { if (tt.log) tt.log.error = v; })}
/>
}
/>
<SettingListItem
title={t('pages.xray.maskAddress')}
description={t('pages.xray.maskAddressDesc')}
paddings="small"
control={
<Select
value={(log.maskAddress as string) || ''}
style={{ width: '100%' }}
options={[{ value: '', label: t('empty') }, ...MASK_ADDRESS.map((s) => ({ value: s, label: s }))]}
onChange={(v) => mutate((tt) => { if (tt.log) tt.log.maskAddress = v; })}
/>
}
/>
<SettingListItem
title={t('pages.xray.dnsLog')}
description={t('pages.xray.dnsLogDesc')}
paddings="small"
control={
<Switch
checked={!!log.dnsLog}
onChange={(v) => mutate((tt) => { if (tt.log) tt.log.dnsLog = v; })}
/>
}
/>
</>
),
},
{
key: '4',
label: t('pages.xray.basicRouting'),
children: (
<>
<Alert
type="warning"
className="mb-12 hint-alert"
title={t('pages.xray.blockConnectionsConfigsDesc')}
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
/>
<SettingListItem
title={t('pages.xray.Torrent')}
paddings="small"
control={
<Switch
checked={torrentActive}
onChange={(checked) => mutate((tt) => {
const next = checked
? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
: blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
ruleSetter(tt, 'blocked', 'protocol', next);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockips')}
paddings="small"
control={
<Select
mode="tags"
value={blockedIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockdomains')}
paddings="small"
control={
<Select
mode="tags"
value={blockedDomains}
style={{ width: '100%' }}
options={BLOCK_DOMAINS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))}
/>
}
/>
<Alert
type="warning"
className="mb-12 hint-alert"
title={t('pages.xray.directConnectionsConfigsDesc')}
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
/>
<SettingListItem
title={t('pages.xray.directips')}
paddings="small"
control={
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) => mutate((tt) => {
ruleSetter(tt, 'direct', 'ip', v);
syncOutbound(tt, 'direct', directSettings);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.directdomains')}
paddings="small"
control={
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
options={DOMAINS_OPTIONS}
onChange={(v) => mutate((tt) => {
ruleSetter(tt, 'direct', 'domain', v);
syncOutbound(tt, 'direct', directSettings);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.ipv4Routing')}
description={t('pages.xray.ipv4RoutingDesc')}
paddings="small"
control={
<Select
mode="tags"
value={ipv4Domains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
onChange={(v) => mutate((tt) => {
ruleSetter(tt, 'IPv4', 'domain', v);
syncOutbound(tt, 'IPv4', ipv4Settings);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.warpRouting')}
description={t('pages.xray.warpRoutingDesc')}
paddings="small"
control={
warpExist ? (
<Select
mode="tags"
value={warpDomains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'warp', 'domain', v))}
/>
) : (
<Button type="primary" onClick={onShowWarp} icon={<CloudOutlined />}>
WARP
</Button>
)
}
/>
<SettingListItem
title={t('pages.xray.nordRouting')}
description={t('pages.xray.nordRoutingDesc')}
paddings="small"
control={
nordExist ? (
<Select
mode="tags"
value={nordDomains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, nordTag, 'domain', v))}
/>
) : (
<Button type="primary" onClick={onShowNord} icon={<ApiOutlined />}>
NordVPN
</Button>
)
}
/>
</>
),
},
{
key: 'reset',
label: t('pages.settings.resetDefaultConfig'),
children: (
<Space style={{ padding: '0 20px' }}>
<Button danger onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')}
</Button>
</Space>
),
},
];
return (
<>
{modalContextHolder}
<Collapse defaultActiveKey={['1']} items={items} />
</>
);
}
-499
View File
@@ -1,499 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import { OutboundDomainStrategies } from '@/models/outbound.js';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
// Phase 6-ii: structured editor for the most-touched fields of the
// xray template — outbound strategy, routing strategy, log levels,
// stat counters, and the "basic routing" lists (block IPs/domains/
// torrent + direct IPs/domains + IPv4 forced + warp/nord domains).
//
// Mutates the parent's templateSettings reactive directly. The
// useXraySetting composable's deep watch on templateSettings re-
// stringifies into xraySetting so the Advanced JSON tab and the
// dirty-poll see every edit.
const props = defineProps({
templateSettings: { type: Object, default: null },
outboundTestUrl: { type: String, default: '' },
warpExist: { type: Boolean, default: false },
nordExist: { type: Boolean, default: false },
});
const emit = defineEmits(['update:outbound-test-url', 'show-warp', 'show-nord', 'reset-default']);
function confirmResetDefault() {
Modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => { emit('reset-default'); },
});
}
// === Static option lists (mirror legacy) =============================
const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
const ACCESS_LOG = ['none', './access.log'];
const ERROR_LOG = ['none', './error.log'];
const MASK_ADDRESS = ['quarter', 'half', 'full'];
const BITTORRENT_PROTOCOLS = ['bittorrent'];
// Country / service lists mirror the legacy panel's settingsData
// (web/html/xray.html on main). Keep additions in sync with that file
// so Vue 3 + legacy stay swappable while the migration finishes.
const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const DOMAINS_OPTIONS = [
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const SERVICES_OPTIONS = [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
// === Routing-rule helpers (matches legacy templateRule{Getter,Setter}) ==
function ruleGetter(outboundTag, property) {
if (!props.templateSettings?.routing?.rules) return [];
const out = [];
for (const rule of props.templateSettings.routing.rules) {
if (
rule
&& Object.prototype.hasOwnProperty.call(rule, property)
&& Object.prototype.hasOwnProperty.call(rule, 'outboundTag')
&& rule.outboundTag === outboundTag
) {
out.push(...rule[property]);
}
}
return out;
}
function ruleSetter(outboundTag, property, data) {
if (!props.templateSettings?.routing) return;
const current = ruleGetter(outboundTag, property);
if (current.length === 0) {
props.templateSettings.routing.rules.push({
type: 'field',
outboundTag,
[property]: data,
});
return;
}
// Replace the property on the FIRST matching rule and drop any later
// duplicates with the same (outboundTag, property) pair (matches the
// legacy single-write-then-filter behavior).
const next = [];
let inserted = false;
for (const rule of props.templateSettings.routing.rules) {
const matches =
rule
&& Object.prototype.hasOwnProperty.call(rule, property)
&& Object.prototype.hasOwnProperty.call(rule, 'outboundTag')
&& rule.outboundTag === outboundTag;
if (matches) {
if (!inserted && data.length > 0) {
rule[property] = data;
next.push(rule);
inserted = true;
}
} else {
next.push(rule);
}
}
props.templateSettings.routing.rules = next;
}
function syncOutbound(tag, settings) {
// After editing direct/IPv4/warp/nord rules, ensure the matching
// outbound exists when the rule list has any entries, and is
// pruned when none remain (legacy syncRulesWithOutbound).
const t = props.templateSettings;
if (!t) return;
const haveRules = t.routing.rules.some((r) => r?.outboundTag === tag);
const idx = t.outbounds.findIndex((o) => o.tag === tag);
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings);
}
// === Computed v-models for every Basics field ========================
function rule(tag, property, syncFn) {
return computed({
get: () => ruleGetter(tag, property),
set: (next) => { ruleSetter(tag, property, next); if (syncFn) syncFn(); },
});
}
const directSettings = { tag: 'direct', protocol: 'freedom' };
const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
const freedomStrategy = computed({
get: () => {
const ob = props.templateSettings?.outbounds?.find(
(o) => o.protocol === 'freedom' && o.tag === 'direct',
);
return ob?.settings?.domainStrategy ?? 'AsIs';
},
set: (next) => {
const t = props.templateSettings;
if (!t) return;
const idx = t.outbounds.findIndex((o) => o.protocol === 'freedom' && o.tag === 'direct');
if (idx < 0) {
t.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: { domainStrategy: next } });
} else {
t.outbounds[idx].settings = t.outbounds[idx].settings || {};
t.outbounds[idx].settings.domainStrategy = next;
}
},
});
const routingStrategy = computed({
get: () => props.templateSettings?.routing?.domainStrategy ?? 'AsIs',
set: (next) => { if (props.templateSettings?.routing) props.templateSettings.routing.domainStrategy = next; },
});
function logField(field, fallback) {
return computed({
get: () => props.templateSettings?.log?.[field] ?? fallback,
set: (next) => { if (props.templateSettings?.log) props.templateSettings.log[field] = next; },
});
}
const logLevel = logField('loglevel', 'warning');
const accessLog = logField('access', '');
const errorLog = logField('error', '');
const maskAddressLog = logField('maskAddress', '');
const dnslog = logField('dnsLog', false);
function policyField(field) {
return computed({
get: () => !!props.templateSettings?.policy?.system?.[field],
set: (next) => {
if (!props.templateSettings?.policy?.system) return;
props.templateSettings.policy.system[field] = next;
},
});
}
const statsInboundUplink = policyField('statsInboundUplink');
const statsInboundDownlink = policyField('statsInboundDownlink');
const statsOutboundUplink = policyField('statsOutboundUplink');
const statsOutboundDownlink = policyField('statsOutboundDownlink');
const blockedIPs = rule('blocked', 'ip');
const blockedDomains = rule('blocked', 'domain');
const blockedProtocols = rule('blocked', 'protocol');
const directIPs = rule('direct', 'ip', () => syncOutbound('direct', directSettings));
const directDomains = rule('direct', 'domain', () => syncOutbound('direct', directSettings));
const ipv4Domains = rule('IPv4', 'domain', () => syncOutbound('IPv4', ipv4Settings));
const warpDomains = rule('warp', 'domain');
const nordTag = computed(() => {
const ob = props.templateSettings?.outbounds?.find((o) => o.tag?.startsWith?.('nord-'));
return ob?.tag || 'nord';
});
const nordDomains = computed({
get: () => ruleGetter(nordTag.value, 'domain'),
set: (next) => ruleSetter(nordTag.value, 'domain', next),
});
const torrentSettings = computed({
get: () => BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.value.includes(p)),
set: (next) => {
if (next) {
blockedProtocols.value = [...blockedProtocols.value, ...BITTORRENT_PROTOCOLS];
} else {
blockedProtocols.value = blockedProtocols.value.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
}
},
});
const localOutboundTestUrl = computed({
get: () => props.outboundTestUrl,
set: (next) => emit('update:outbound-test-url', next),
});
</script>
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.generalConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.FreedomStrategy') }}</template>
<template #description>{{ t('pages.xray.FreedomStrategyDesc') }}</template>
<template #control>
<a-select v-model:value="freedomStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.RoutingStrategy') }}</template>
<template #description>{{ t('pages.xray.RoutingStrategyDesc') }}</template>
<template #control>
<a-select v-model:value="routingStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in ROUTING_DOMAIN_STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.outboundTestUrl') }}</template>
<template #description>{{ t('pages.xray.outboundTestUrlDesc') }}</template>
<template #control>
<a-input v-model:value="localOutboundTestUrl" placeholder="https://www.google.com/generate_204" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" :header="t('pages.xray.statistics')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.statsInboundUplink') }}</template>
<template #control><a-switch v-model:checked="statsInboundUplink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.statsInboundDownlink') }}</template>
<template #control><a-switch v-model:checked="statsInboundDownlink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.statsOutboundUplink') }}</template>
<template #control><a-switch v-model:checked="statsOutboundUplink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Outbound downlink stats</template>
<template #control><a-switch v-model:checked="statsOutboundDownlink" /></template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.xray.logConfigs')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.logConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.logLevel') }}</template>
<template #description>{{ t('pages.xray.logLevelDesc') }}</template>
<template #control>
<a-select v-model:value="logLevel" :style="{ width: '100%' }">
<a-select-option v-for="s in LOG_LEVELS" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.accessLog') }}</template>
<template #description>{{ t('pages.xray.accessLogDesc') }}</template>
<template #control>
<a-select v-model:value="accessLog" :style="{ width: '100%' }">
<a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.errorLog') }}</template>
<template #description>{{ t('pages.xray.errorLogDesc') }}</template>
<template #control>
<a-select v-model:value="errorLog" :style="{ width: '100%' }">
<a-select-option value="">{{ t('empty') }}</a-select-option>
<a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.maskAddress') }}</template>
<template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
<template #control>
<a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
<a-select-option value="">{{ t('empty') }}</a-select-option>
<a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dnsLog') }}</template>
<template #description>{{ t('pages.xray.dnsLogDesc') }}</template>
<template #control><a-switch v-model:checked="dnslog" /></template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" :header="t('pages.xray.basicRouting')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.blockConnectionsConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.Torrent') }}</template>
<template #control><a-switch v-model:checked="torrentSettings" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.blockips') }}</template>
<template #control>
<a-select v-model:value="blockedIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.blockdomains') }}</template>
<template #control>
<a-select v-model:value="blockedDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{
p.label }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.directConnectionsConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.directips') }}</template>
<template #control>
<a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.directdomains') }}</template>
<template #control>
<a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.ipv4Routing') }}</template>
<template #description>{{ t('pages.xray.ipv4RoutingDesc') }}</template>
<template #control>
<a-select v-model:value="ipv4Domains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.warpRouting') }}</template>
<template #description>{{ t('pages.xray.warpRoutingDesc') }}</template>
<template #control>
<a-select v-if="warpExist" v-model:value="warpDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
<a-button v-else type="primary" @click="emit('show-warp')">
<template #icon>
<CloudOutlined />
</template>
WARP
</a-button>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.nordRouting') }}</template>
<template #description>{{ t('pages.xray.nordRoutingDesc') }}</template>
<template #control>
<a-select v-if="nordExist" v-model:value="nordDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
<a-button v-else type="primary" @click="emit('show-nord')">
<template #icon>
<ApiOutlined />
</template>
NordVPN
</a-button>
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="reset" :header="t('pages.settings.resetDefaultConfig')">
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button danger @click="confirmResetDefault">
{{ t('pages.settings.resetDefaultConfig') }}
</a-button>
</a-space>
</a-collapse-panel>
</a-collapse>
</template>
<style scoped>
.mb-12 {
margin-bottom: 12px;
}
.hint-alert {
text-align: center;
}
</style>
@@ -0,0 +1,32 @@
.preset-list {
border: 1px solid rgba(5, 5, 5, 0.06);
border-radius: 8px;
overflow: hidden;
}
body.dark .preset-list,
html[data-theme='ultra-dark'] .preset-list {
border-color: rgba(255, 255, 255, 0.12);
}
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.preset-row:last-child {
border-bottom: 0;
}
body.dark .preset-row,
html[data-theme='ultra-dark'] .preset-row {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.preset-name {
font-weight: 500;
}
@@ -0,0 +1,67 @@
import { useTranslation } from 'react-i18next';
import { Button, Modal, Space, Tag } from 'antd';
import './DnsPresetsModal.css';
interface DnsPresetsModalProps {
open: boolean;
onClose: () => void;
onInstall: (servers: string[]) => void;
}
const PRESETS: { name: string; family: boolean; data: string[] }[] = [
{
name: 'Google DNS',
family: false,
data: ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888', '2001:4860:4860::8844'],
},
{
name: 'Cloudflare DNS',
family: false,
data: ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111', '2606:4700:4700::1001'],
},
{
name: 'AdGuard DNS',
family: false,
data: ['94.140.14.14', '94.140.15.15', '2a10:50c0::ad1:ff', '2a10:50c0::ad2:ff'],
},
{
name: 'AdGuard Family DNS',
family: true,
data: ['94.140.14.15', '94.140.15.16', '2a10:50c0::bad1:ff', '2a10:50c0::bad2:ff'],
},
{
name: 'Cloudflare Family DNS',
family: true,
data: ['1.1.1.3', '1.0.0.3', '2606:4700:4700::1113', '2606:4700:4700::1003'],
},
];
export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresetsModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={t('pages.xray.dns.dnsPresetTitle')}
footer={null}
mask={{ closable: false }}
onCancel={onClose}
>
<div className="preset-list">
{PRESETS.map((preset) => (
<div key={preset.name} className="preset-row">
<Space size="small" align="center">
<Tag color={preset.family ? 'purple' : 'green'}>
{preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS'}
</Tag>
<span className="preset-name">{preset.name}</span>
</Space>
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
{t('install')}
</Button>
</div>
))}
</div>
</Modal>
);
}
-103
View File
@@ -1,103 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'install']);
const PRESETS = [
{
name: 'Google DNS',
family: false,
data: [
'8.8.8.8',
'8.8.4.4',
'2001:4860:4860::8888',
'2001:4860:4860::8844',
],
},
{
name: 'Cloudflare DNS',
family: false,
data: [
'1.1.1.1',
'1.0.0.1',
'2606:4700:4700::1111',
'2606:4700:4700::1001',
],
},
{
name: 'AdGuard DNS',
family: false,
data: [
'94.140.14.14',
'94.140.15.15',
'2a10:50c0::ad1:ff',
'2a10:50c0::ad2:ff',
],
},
{
name: 'AdGuard Family DNS',
family: true,
data: [
'94.140.14.15',
'94.140.15.16',
'2a10:50c0::bad1:ff',
'2a10:50c0::bad2:ff',
],
},
{
name: 'Cloudflare Family DNS',
family: true,
data: [
'1.1.1.3',
'1.0.0.3',
'2606:4700:4700::1113',
'2606:4700:4700::1003',
],
},
];
const title = computed(() => t('pages.xray.dns.dnsPresetTitle'));
function close() { emit('update:open', false); }
function install(preset) {
emit('install', [...preset.data]);
}
</script>
<template>
<a-modal :open="open" :title="title" :footer="null" :mask-closable="false" @cancel="close">
<a-list bordered>
<a-list-item v-for="preset in PRESETS" :key="preset.name" class="preset-row">
<a-space size="small" align="center">
<a-tag :color="preset.family ? 'purple' : 'green'">
{{ preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS' }}
</a-tag>
<span class="preset-name">{{ preset.name }}</span>
</a-space>
<a-button type="primary" size="small" @click="install(preset)">
{{ t('install') }}
</a-button>
</a-list-item>
</a-list>
</a-modal>
</template>
<style scoped>
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preset-name {
font-weight: 500;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
export type DnsServerValue =
| string
| {
address: string;
port?: number;
domains?: string[];
expectedIPs?: string[];
expectIPs?: string[];
unexpectedIPs?: string[];
queryStrategy?: string;
skipFallback?: boolean;
disableCache?: boolean;
finalQuery?: boolean;
tag?: string;
clientIP?: string;
serveStale?: boolean;
serveExpiredTTL?: number;
timeoutMs?: number;
[key: string]: unknown;
};
interface DnsServerModalProps {
open: boolean;
server: DnsServerValue | null;
isEdit: boolean;
onClose: () => void;
onConfirm: (value: DnsServerValue) => void;
}
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
interface DnsForm {
address: string;
port: number;
domains: string[];
expectedIPs: string[];
unexpectedIPs: string[];
queryStrategy: string;
skipFallback: boolean;
disableCache: boolean;
finalQuery: boolean;
tag: string;
clientIP: string;
serveStale: boolean;
serveExpiredTTL: number;
timeoutMs: number;
}
function defaultServer(): DnsForm {
return {
address: 'localhost',
port: 53,
domains: [],
expectedIPs: [],
unexpectedIPs: [],
queryStrategy: 'UseIP',
skipFallback: false,
disableCache: false,
finalQuery: false,
tag: '',
clientIP: '',
serveStale: false,
serveExpiredTTL: 0,
timeoutMs: 4000,
};
}
export default function DnsServerModal({
open,
server,
isEdit,
onClose,
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<DnsForm>(defaultServer());
useEffect(() => {
if (!open) return;
if (server == null) {
setForm(defaultServer());
return;
}
if (typeof server === 'string') {
setForm({ ...defaultServer(), address: server });
return;
}
setForm({
...defaultServer(),
...server,
domains: [...(server.domains || [])],
expectedIPs: [...(server.expectedIPs || server.expectIPs || [])],
unexpectedIPs: [...(server.unexpectedIPs || [])],
});
}, [open, server]);
const update = <K extends keyof DnsForm>(key: K, value: DnsForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
function updateList(key: 'domains' | 'expectedIPs' | 'unexpectedIPs', mutator: (next: string[]) => void) {
setForm((prev) => {
const next = [...prev[key]];
mutator(next);
return { ...prev, [key]: next };
});
}
function submit() {
const isPlain =
form.domains.length === 0 &&
form.expectedIPs.length === 0 &&
form.unexpectedIPs.length === 0 &&
form.port === 53 &&
form.queryStrategy === 'UseIP' &&
form.skipFallback === false &&
form.disableCache === false &&
form.finalQuery === false &&
!form.tag &&
!form.clientIP &&
form.serveStale === false &&
form.serveExpiredTTL === 0 &&
form.timeoutMs === 4000;
if (isPlain) {
onConfirm(form.address);
return;
}
const out: Record<string, unknown> = {
address: form.address,
port: form.port,
domains: form.domains.filter(Boolean),
expectedIPs: form.expectedIPs.filter(Boolean),
unexpectedIPs: form.unexpectedIPs.filter(Boolean),
queryStrategy: form.queryStrategy,
skipFallback: form.skipFallback,
disableCache: form.disableCache,
finalQuery: form.finalQuery,
serveStale: form.serveStale,
serveExpiredTTL: form.serveExpiredTTL,
timeoutMs: form.timeoutMs,
};
if (form.tag) out.tag = form.tag;
if (form.clientIP) out.clientIP = form.clientIP;
onConfirm(out as DnsServerValue);
}
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
return (
<Modal
open={open}
title={title}
okText={t('confirm')}
cancelText={t('close')}
mask={{ closable: false }}
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('pages.inbounds.address')}>
<Input value={form.address} onChange={(e) => update('address', e.target.value)} />
</Form.Item>
<Form.Item label={t('pages.inbounds.port')}>
<InputNumber value={form.port} min={1} max={65535} onChange={(v) => update('port', Number(v) || 53)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.tag')}>
<Input value={form.tag} onChange={(e) => update('tag', e.target.value)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.clientIp')}>
<Input value={form.clientIP} onChange={(e) => update('clientIP', e.target.value)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.strategy')}>
<Select
value={form.queryStrategy}
onChange={(v) => update('queryStrategy', v)}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</Form.Item>
<Form.Item label={t('pages.xray.dns.timeoutMs')}>
<InputNumber value={form.timeoutMs} min={0} step={500} onChange={(v) => update('timeoutMs', Number(v) || 0)} />
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
{form.domains.map((value, idx) => (
<Space.Compact key={`d${idx}`} block style={{ marginTop: 4 }}>
<Input
value={value}
onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
/>
<InputAddon onClick={() => updateList('domains', (d) => d.splice(idx, 1))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
{form.expectedIPs.map((value, idx) => (
<Space.Compact key={`e${idx}`} block style={{ marginTop: 4 }}>
<Input
value={value}
onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
/>
<InputAddon onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
{form.unexpectedIPs.map((value, idx) => (
<Space.Compact key={`u${idx}`} block style={{ marginTop: 4 }}>
<Input
value={value}
onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
/>
<InputAddon onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.skipFallback')}>
<Switch checked={form.skipFallback} onChange={(v) => update('skipFallback', v)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.finalQuery')}>
<Switch checked={form.finalQuery} onChange={(v) => update('finalQuery', v)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.disableCache')}>
<Switch checked={form.disableCache} onChange={(v) => update('disableCache', v)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.serveStale')}>
<Switch checked={form.serveStale} onChange={(v) => update('serveStale', v)} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.serveExpiredTTL')}>
<InputNumber
value={form.serveExpiredTTL}
min={0}
step={60}
onChange={(v) => update('serveExpiredTTL', Number(v) || 0)}
/>
</Form.Item>
</Form>
</Modal>
);
}
-190
View File
@@ -1,190 +0,0 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
server: { type: [Object, String, null], default: null },
isEdit: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'confirm']);
const DEFAULT_SERVER = () => ({
address: 'localhost',
port: 53,
domains: [],
expectedIPs: [],
unexpectedIPs: [],
queryStrategy: 'UseIP',
skipFallback: false,
disableCache: false,
finalQuery: false,
tag: '',
clientIP: '',
serveStale: false,
serveExpiredTTL: 0,
timeoutMs: 4000,
});
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const form = reactive(DEFAULT_SERVER());
watch(() => props.open, (next) => {
if (!next) return;
Object.assign(form, DEFAULT_SERVER());
if (props.server == null) return;
if (typeof props.server === 'string') {
form.address = props.server;
return;
}
const incoming = props.server;
Object.assign(form, {
...DEFAULT_SERVER(),
...incoming,
domains: [...(incoming.domains || [])],
expectedIPs: [...(incoming.expectedIPs || incoming.expectIPs || [])],
unexpectedIPs: [...(incoming.unexpectedIPs || [])],
});
});
function close() { emit('update:open', false); }
function onOk() {
const isPlain = form.domains.length === 0
&& form.expectedIPs.length === 0
&& form.unexpectedIPs.length === 0
&& form.port === 53
&& form.queryStrategy === 'UseIP'
&& form.skipFallback === false
&& form.disableCache === false
&& form.finalQuery === false
&& !form.tag
&& !form.clientIP
&& form.serveStale === false
&& form.serveExpiredTTL === 0
&& form.timeoutMs === 4000;
if (isPlain) {
emit('confirm', form.address);
return;
}
const out = {
address: form.address,
port: form.port,
domains: [...form.domains].filter(Boolean),
expectedIPs: [...form.expectedIPs].filter(Boolean),
unexpectedIPs: [...form.unexpectedIPs].filter(Boolean),
queryStrategy: form.queryStrategy,
skipFallback: form.skipFallback,
disableCache: form.disableCache,
finalQuery: form.finalQuery,
serveStale: form.serveStale,
serveExpiredTTL: form.serveExpiredTTL,
timeoutMs: form.timeoutMs,
};
if (form.tag) out.tag = form.tag;
if (form.clientIP) out.clientIP = form.clientIP;
emit('confirm', out);
}
const title = computed(() =>
props.isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add'),
);
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="t('confirm')" :cancel-text="t('close')" :mask-closable="false"
@ok="onOk" @cancel="close">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item :label="t('pages.inbounds.address')">
<a-input v-model:value="form.address" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.port')">
<a-input-number v-model:value="form.port" :min="1" :max="65535" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.tag')">
<a-input v-model:value="form.tag" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.clientIp')">
<a-input v-model:value="form.clientIP" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.strategy')">
<a-select v-model:value="form.queryStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="t('pages.xray.dns.timeoutMs')">
<a-input-number v-model:value="form.timeoutMs" :min="0" :step="500" />
</a-form-item>
<a-divider :style="{ margin: '5px 0' }" />
<a-form-item :label="t('pages.xray.dns.domains')">
<a-button size="small" type="primary" @click="form.domains.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<template v-for="(_, idx) in form.domains" :key="`d${idx}`">
<a-input v-model:value="form.domains[idx]" :style="{ marginTop: '4px' }">
<template #addonAfter>
<MinusOutlined @click="form.domains.splice(idx, 1)" />
</template>
</a-input>
</template>
</a-form-item>
<a-form-item :label="t('pages.xray.dns.expectIPs')">
<a-button size="small" type="primary" @click="form.expectedIPs.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<template v-for="(_, idx) in form.expectedIPs" :key="`e${idx}`">
<a-input v-model:value="form.expectedIPs[idx]" :style="{ marginTop: '4px' }">
<template #addonAfter>
<MinusOutlined @click="form.expectedIPs.splice(idx, 1)" />
</template>
</a-input>
</template>
</a-form-item>
<a-form-item :label="t('pages.xray.dns.unexpectIPs')">
<a-button size="small" type="primary" @click="form.unexpectedIPs.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<template v-for="(_, idx) in form.unexpectedIPs" :key="`u${idx}`">
<a-input v-model:value="form.unexpectedIPs[idx]" :style="{ marginTop: '4px' }">
<template #addonAfter>
<MinusOutlined @click="form.unexpectedIPs.splice(idx, 1)" />
</template>
</a-input>
</template>
</a-form-item>
<a-divider :style="{ margin: '5px 0' }" />
<a-form-item :label="t('pages.xray.dns.skipFallback')">
<a-switch v-model:checked="form.skipFallback" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.finalQuery')">
<a-switch v-model:checked="form.finalQuery" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.disableCache')">
<a-switch v-model:checked="form.disableCache" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.serveStale')">
<a-switch v-model:checked="form.serveStale" />
</a-form-item>
<a-form-item :label="t('pages.xray.dns.serveExpiredTTL')">
<a-input-number v-model:value="form.serveExpiredTTL" :min="0" :step="60" />
</a-form-item>
</a-form>
</a-modal>
</template>
+16
View File
@@ -0,0 +1,16 @@
.row-index {
font-weight: 500;
opacity: 0.7;
}
.muted {
opacity: 0.7;
word-break: break-all;
}
.hosts-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
+551
View File
@@ -0,0 +1,551 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Collapse, Dropdown, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table } from 'antd';
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import SettingListItem from '@/components/SettingListItem';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './DnsTab.css';
interface DnsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
interface DnsConfig {
tag?: string;
clientIp?: string;
queryStrategy?: string;
disableCache?: boolean;
disableFallback?: boolean;
disableFallbackIfMatch?: boolean;
enableParallelQuery?: boolean;
useSystemHosts?: boolean;
serveStale?: boolean;
serveExpiredTTL?: number;
hosts?: Record<string, string | string[]>;
servers?: DnsServerValue[];
}
interface HostRow {
domain: string;
values: string[];
}
interface FakednsRow {
ipPool: string;
poolSize: number;
}
function addrFor(server: DnsServerValue): string {
return typeof server === 'string' ? server : server?.address || '';
}
function domainsFor(server: DnsServerValue): string {
return typeof server === 'object' && server !== null ? (server.domains || []).join(',') : '';
}
function expectedIPsFor(server: DnsServerValue): string {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false);
const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [presetsModalOpen, setPresetsModalOpen] = useState(false);
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function toggleDNS(enabled: boolean) {
mutate((next) => {
if (enabled) {
(next as { dns?: DnsConfig }).dns = {
tag: 'dns_inbound',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false,
serveStale: false,
serveExpiredTTL: 0,
hosts: {},
servers: [],
};
next.fakedns = null;
} else {
delete next.dns;
delete next.fakedns;
}
});
}
useEffect(() => {
if (!dns) {
setHostsList([]);
return;
}
const src = dns.hosts || {};
setHostsList(
Object.entries(src).map(([domain, val]) => ({
domain,
values: Array.isArray(val) ? [...val] : [String(val)],
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dnsEnabled]);
function syncHosts(next: HostRow[]) {
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
delete (tt.dns as DnsConfig).hosts;
}
});
}
function setDnsField<K extends keyof DnsConfig>(key: K, value: DnsConfig[K], omit = false) {
mutate((tt) => {
if (!tt.dns) return;
if (omit && (value == null || (typeof value === 'string' && value.trim() === ''))) {
delete (tt.dns as Record<string, unknown>)[key as string];
} else {
(tt.dns as Record<string, unknown>)[key as string] = value;
}
});
}
const dnsServers = useMemo(() => {
const list = dns?.servers || [];
return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]);
const dnsColumns: ColumnsType<{ key: number; server: DnsServerValue }> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEditServer(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => deleteServer(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addrFor(record.server),
},
{
title: t('pages.xray.dns.domains'),
key: 'domains',
align: 'left',
render: (_v, record) => <span className="muted">{domainsFor(record.server)}</span>,
},
{
title: t('pages.xray.dns.expectIPs'),
key: 'expectedIPs',
align: 'left',
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t],
);
function openAddServer() {
setEditingServer(null);
setEditingIndex(null);
setServerModalOpen(true);
}
function openEditServer(idx: number) {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
}
function onServerConfirm(value: DnsServerValue) {
mutate((tt) => {
if (!tt.dns) return;
const cfg = tt.dns as DnsConfig;
if (!Array.isArray(cfg.servers)) cfg.servers = [];
if (editingIndex == null) cfg.servers.push(value);
else cfg.servers[editingIndex] = value;
});
setServerModalOpen(false);
}
function deleteServer(idx: number) {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
}
function clearAllServers() {
modal.confirm({
title: t('pages.xray.dns.clearAllTitle'),
content: t('pages.xray.dns.clearAllConfirm'),
okText: t('delete'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: () => mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = [];
}),
});
}
function onPresetInstall(servers: string[]) {
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = servers;
});
setPresetsModalOpen(false);
}
const fakeDnsList = useMemo<{ key: number; ipPool: string; poolSize: number }[]>(() => {
const list = Array.isArray(templateSettings?.fakedns)
? (templateSettings?.fakedns as FakednsRow[])
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]);
const fakednsColumns: ColumnsType<{ key: number; ipPool: string; poolSize: number }> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button shape="circle" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteFakedns(index)} />
</Space>
),
},
{
title: 'IP pool',
dataIndex: 'ipPool',
key: 'ipPool',
align: 'left',
render: (_v, record, index) => (
<Input
value={record.ipPool}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
),
},
{
title: 'Pool size',
dataIndex: 'poolSize',
key: 'poolSize',
align: 'right',
width: 120,
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
min={1}
size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
/>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
function addFakedns() {
mutate((tt) => {
if (!Array.isArray(tt.fakedns)) tt.fakedns = [];
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
});
}
function deleteFakedns(idx: number) {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
}
function updateFakednsField(idx: number, field: 'ipPool' | 'poolSize', value: string | number) {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
}
const items = useMemo(() => {
const out = [
{
key: '1',
label: t('pages.xray.generalConfigs'),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.enable')}
description={t('pages.xray.dns.enableDesc')}
control={<Switch checked={dnsEnabled} onChange={toggleDNS} />}
/>
{dnsEnabled && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.tag')}
description={t('pages.xray.dns.tagDesc')}
control={
<Input
value={dns?.tag ?? 'dns_inbound'}
onChange={(e) => setDnsField('tag', e.target.value)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.clientIp')}
description={t('pages.xray.dns.clientIpDesc')}
control={
<Input
value={dns?.clientIp ?? ''}
onChange={(e) => setDnsField('clientIp', e.target.value, true)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.strategy')}
description={t('pages.xray.dns.strategyDesc')}
control={
<Select
value={dns?.queryStrategy ?? 'UseIP'}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(v) => setDnsField('queryStrategy', v)}
/>
}
/>
{(
[
['disableCache', 'pages.xray.dns.disableCache', 'pages.xray.dns.disableCacheDesc'],
['disableFallback', 'pages.xray.dns.disableFallback', 'pages.xray.dns.disableFallbackDesc'],
['disableFallbackIfMatch', 'pages.xray.dns.disableFallbackIfMatch', 'pages.xray.dns.disableFallbackIfMatchDesc'],
['enableParallelQuery', 'pages.xray.dns.enableParallelQuery', 'pages.xray.dns.enableParallelQueryDesc'],
['useSystemHosts', 'pages.xray.dns.useSystemHosts', 'pages.xray.dns.useSystemHostsDesc'],
['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
] as const
).map(([field, titleKey, descKey]) => (
<SettingListItem
key={field}
paddings="small"
title={t(titleKey)}
description={t(descKey)}
control={
<Switch
checked={!!dns?.[field]}
onChange={(v) => setDnsField(field as keyof DnsConfig, v as never)}
/>
}
/>
))}
<SettingListItem
paddings="small"
title={t('pages.xray.dns.serveExpiredTTL')}
description={t('pages.xray.dns.serveExpiredTTLDesc')}
control={
<InputNumber
value={dns?.serveExpiredTTL ?? 0}
min={0}
step={60}
style={{ width: '100%' }}
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
/>
}
/>
</>
)}
</>
),
},
];
if (dnsEnabled) {
out.push({
key: 'hosts',
label: t('pages.xray.dns.hosts'),
children: hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
{t('pages.xray.dns.hostsAdd')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
{t('pages.xray.dns.hostsAdd')}
</Button>
{hostsList.map((row, idx) => (
<div key={`h${idx}`} className="hosts-row">
<Input
value={row.domain}
placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }}
onChange={(e) => {
const next = hostsList.map((r, i) => (i === idx ? { ...r, domain: e.target.value } : r));
syncHosts(next);
}}
/>
<Select
mode="tags"
value={row.values}
placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']}
onChange={(values) => {
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
syncHosts(next);
}}
/>
<Button danger icon={<DeleteOutlined />} onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))} />
</div>
))}
</Space>
),
});
out.push({
key: '2',
label: 'DNS',
children: dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
</Space>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
{t('pages.xray.dns.clearAll')}
</Button>
</Space>
<Table
columns={dnsColumns}
dataSource={dnsServers}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
out.push({
key: '3',
label: 'Fake DNS',
children: fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
<Table
columns={fakednsColumns}
dataSource={fakeDnsList}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return (
<>
{modalContextHolder}
<Collapse defaultActiveKey={['1']} items={items} />
<DnsServerModal
open={serverModalOpen}
server={editingServer}
isEdit={editingIndex != null}
onClose={() => setServerModalOpen(false)}
onConfirm={onServerConfirm}
/>
<DnsPresetsModal
open={presetsModalOpen}
onClose={() => setPresetsModalOpen(false)}
onInstall={onPresetInstall}
/>
</>
);
}
-532
View File
@@ -1,532 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal } from 'ant-design-vue';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
MenuOutlined,
} from '@ant-design/icons-vue';
import SettingListItem from '@/components/SettingListItem.vue';
import DnsServerModal from './DnsServerModal.vue';
import DnsPresetsModal from './DnsPresetsModal.vue';
const { t } = useI18n();
const props = defineProps({
templateSettings: { type: Object, default: null },
});
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const dnsFieldOmit = Object.freeze(Object.create(null));
function dnsValueEmptyForOmit(v) {
if (v === undefined || v === null) return true;
if (typeof v === 'string') return v.trim() === '';
return false;
}
const enableDNS = computed({
get: () => !!props.templateSettings?.dns,
set: (next) => {
if (!props.templateSettings) return;
if (next) {
props.templateSettings.dns = {
tag: 'dns_inbound',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false,
serveStale: false,
serveExpiredTTL: 0,
hosts: {},
servers: [],
};
props.templateSettings.fakedns = null;
} else {
delete props.templateSettings.dns;
delete props.templateSettings.fakedns;
}
},
});
function dnsField(field, fallback) {
const omitWhenUnset = fallback === dnsFieldOmit;
return computed({
get: () => {
const raw = props.templateSettings?.dns?.[field];
if (fallback === dnsFieldOmit) return raw ?? '';
return raw ?? fallback;
},
set: (v) => {
if (!props.templateSettings?.dns) return;
if (omitWhenUnset) {
if (dnsValueEmptyForOmit(v)) {
if (field in props.templateSettings.dns) delete props.templateSettings.dns[field];
} else {
props.templateSettings.dns[field] = v;
}
} else {
props.templateSettings.dns[field] = v;
}
},
});
}
const dnsTag = dnsField('tag', 'dns_inbound');
const dnsClientIp = dnsField('clientIp', dnsFieldOmit);
const dnsStrategy = dnsField('queryStrategy', 'UseIP');
const dnsDisableCache = dnsField('disableCache', false);
const dnsDisableFallback = dnsField('disableFallback', false);
const dnsDisableFallbackIfMatch = dnsField('disableFallbackIfMatch', false);
const dnsEnableParallelQuery = dnsField('enableParallelQuery', false);
const dnsUseSystemHosts = dnsField('useSystemHosts', false);
const dnsServeStale = dnsField('serveStale', false);
const dnsServeExpiredTTL = dnsField('serveExpiredTTL', 0);
const hostsList = ref([]);
function hydrateHostsFromBackend() {
const src = props.templateSettings?.dns?.hosts || {};
hostsList.value = Object.entries(src).map(([domain, val]) => ({
domain,
values: Array.isArray(val) ? [...val] : [String(val)],
}));
}
function syncHostsToBackend() {
if (!props.templateSettings?.dns) return;
const obj = {};
for (const row of hostsList.value) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
if (Object.keys(obj).length > 0) {
props.templateSettings.dns.hosts = obj;
} else if ('hosts' in props.templateSettings.dns) {
delete props.templateSettings.dns.hosts;
}
}
watch(
() => !!props.templateSettings?.dns,
(enabled) => {
if (enabled) hydrateHostsFromBackend();
else hostsList.value = [];
},
{ immediate: true },
);
watch(hostsList, syncHostsToBackend, { deep: true });
function addHost() {
hostsList.value.push({ domain: '', values: [] });
}
function deleteHost(idx) {
hostsList.value.splice(idx, 1);
}
const dnsServers = computed(() => {
const list = props.templateSettings?.dns?.servers || [];
return list.map((s, idx) => ({ key: idx, server: s }));
});
const dnsColumns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 60 },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left' },
{ title: t('pages.xray.dns.domains'), key: 'domains', align: 'left' },
{ title: t('pages.xray.dns.expectIPs'), key: 'expectedIPs', align: 'left' },
]);
function addrFor(server) {
return typeof server === 'string' ? server : server?.address || '';
}
function domainsFor(server) {
return typeof server === 'object' ? (server.domains || []).join(',') : '';
}
function expectedIPsFor(server) {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
// ============== Server modal ==============
const serverModalOpen = ref(false);
const editingServer = ref(null);
const editingIndex = ref(null);
function openAddServer() {
editingServer.value = null;
editingIndex.value = null;
serverModalOpen.value = true;
}
function openEditServer(idx) {
editingServer.value = props.templateSettings.dns.servers[idx];
editingIndex.value = idx;
serverModalOpen.value = true;
}
function onServerConfirm(value) {
if (!props.templateSettings?.dns) return;
if (!Array.isArray(props.templateSettings.dns.servers)) {
props.templateSettings.dns.servers = [];
}
if (editingIndex.value == null) {
props.templateSettings.dns.servers.push(value);
} else {
props.templateSettings.dns.servers[editingIndex.value] = value;
}
serverModalOpen.value = false;
}
function deleteServer(idx) {
props.templateSettings.dns.servers.splice(idx, 1);
}
function clearAllServers() {
if (!props.templateSettings?.dns) return;
Modal.confirm({
title: t('pages.xray.dns.clearAllTitle'),
content: t('pages.xray.dns.clearAllConfirm'),
okText: t('delete'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk() {
props.templateSettings.dns.servers = [];
},
});
}
const presetsModalOpen = ref(false);
function openPresets() { presetsModalOpen.value = true; }
function onPresetInstall(serverList) {
if (!props.templateSettings?.dns) return;
props.templateSettings.dns.servers = serverList;
presetsModalOpen.value = false;
}
// ============== Fake DNS table ==============
const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
const fakeDnsList = computed(() => {
const list = Array.isArray(props.templateSettings?.fakedns)
? props.templateSettings.fakedns
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
});
const fakednsColumns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 60 },
{ title: 'IP pool', dataIndex: 'ipPool', key: 'ipPool', align: 'left' },
{ title: 'Pool size', dataIndex: 'poolSize', key: 'poolSize', align: 'right', width: 120 },
]);
function addFakedns() {
if (!props.templateSettings) return;
if (!Array.isArray(props.templateSettings.fakedns)) {
props.templateSettings.fakedns = [];
}
props.templateSettings.fakedns.push(DEFAULT_FAKEDNS());
}
function deleteFakedns(idx) {
props.templateSettings.fakedns.splice(idx, 1);
if (props.templateSettings.fakedns.length === 0) {
props.templateSettings.fakedns = null;
}
}
function updateFakednsField(idx, field, value) {
if (!props.templateSettings.fakedns?.[idx]) return;
props.templateSettings.fakedns[idx] = {
...props.templateSettings.fakedns[idx],
[field]: value,
};
}
</script>
<template>
<a-collapse default-active-key="1">
<!-- ============== General DNS settings ============== -->
<a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.enable') }}</template>
<template #description>{{ t('pages.xray.dns.enableDesc') }}</template>
<template #control>
<a-switch v-model:checked="enableDNS" />
</template>
</SettingListItem>
<template v-if="enableDNS">
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.tag') }}</template>
<template #description>{{ t('pages.xray.dns.tagDesc') }}</template>
<template #control>
<a-input v-model:value="dnsTag" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.clientIp') }}</template>
<template #description>{{ t('pages.xray.dns.clientIpDesc') }}</template>
<template #control>
<a-input v-model:value="dnsClientIp" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.strategy') }}</template>
<template #description>{{ t('pages.xray.dns.strategyDesc') }}</template>
<template #control>
<a-select v-model:value="dnsStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.disableCache') }}</template>
<template #description>{{ t('pages.xray.dns.disableCacheDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsDisableCache" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.disableFallback') }}</template>
<template #description>{{ t('pages.xray.dns.disableFallbackDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsDisableFallback" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.disableFallbackIfMatch') }}</template>
<template #description>{{ t('pages.xray.dns.disableFallbackIfMatchDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsDisableFallbackIfMatch" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.enableParallelQuery') }}</template>
<template #description>{{ t('pages.xray.dns.enableParallelQueryDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsEnableParallelQuery" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.useSystemHosts') }}</template>
<template #description>{{ t('pages.xray.dns.useSystemHostsDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsUseSystemHosts" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.serveStale') }}</template>
<template #description>{{ t('pages.xray.dns.serveStaleDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsServeStale" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.serveExpiredTTL') }}</template>
<template #description>{{ t('pages.xray.dns.serveExpiredTTLDesc') }}</template>
<template #control>
<a-input-number v-model:value="dnsServeExpiredTTL" :min="0" :step="60" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</template>
</a-collapse-panel>
<!-- ============== Hosts ============== -->
<a-collapse-panel v-if="enableDNS" key="hosts" :header="t('pages.xray.dns.hosts')">
<a-empty v-if="hostsList.length === 0" :description="t('pages.xray.dns.hostsEmpty')">
<a-button type="primary" @click="addHost">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.dns.hostsAdd') }}
</a-button>
</a-empty>
<template v-else>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-button type="primary" @click="addHost">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.dns.hostsAdd') }}
</a-button>
<div v-for="(row, idx) in hostsList" :key="`h${idx}`" class="hosts-row">
<a-input v-model:value="row.domain" :placeholder="t('pages.xray.dns.hostsDomain')"
:style="{ flex: '1 1 220px' }" />
<a-select v-model:value="row.values" mode="tags" :placeholder="t('pages.xray.dns.hostsValues')"
:style="{ flex: '2 1 320px' }" :token-separators="[',', ' ']" />
<a-button danger @click="deleteHost(idx)">
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</div>
</a-space>
</template>
</a-collapse-panel>
<!-- ============== DNS servers ============== -->
<a-collapse-panel v-if="enableDNS" key="2" header="DNS">
<a-empty v-if="dnsServers.length === 0" :description="t('emptyDnsDesc')">
<a-space>
<a-button type="primary" @click="openAddServer">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.dns.add') }}
</a-button>
<a-button @click="openPresets">
<template #icon>
<MenuOutlined />
</template>
{{ t('pages.xray.dns.usePreset') }}
</a-button>
</a-space>
</a-empty>
<template v-else>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-space wrap>
<a-button type="primary" @click="openAddServer">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.dns.add') }}
</a-button>
<a-button @click="openPresets">
<template #icon>
<MenuOutlined />
</template>
{{ t('pages.xray.dns.usePreset') }}
</a-button>
<a-button danger @click="clearAllServers">
<template #icon>
<DeleteOutlined />
</template>
{{ t('pages.xray.dns.clearAll') }}
</a-button>
</a-space>
<a-table :columns="dnsColumns" :data-source="dnsServers" :row-key="(r) => r.key" :pagination="false"
size="small" bordered>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<a-space :size="6">
<span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEditServer(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="deleteServer(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
<template v-else-if="column.key === 'address'">
{{ addrFor(record.server) }}
</template>
<template v-else-if="column.key === 'domains'">
<span class="muted">{{ domainsFor(record.server) }}</span>
</template>
<template v-else-if="column.key === 'expectedIPs'">
<span class="muted">{{ expectedIPsFor(record.server) }}</span>
</template>
</template>
</a-table>
</a-space>
</template>
</a-collapse-panel>
<!-- ============== Fake DNS ============== -->
<a-collapse-panel v-if="enableDNS" key="3" header="Fake DNS">
<a-empty v-if="fakeDnsList.length === 0" :description="t('emptyFakeDnsDesc')">
<a-button type="primary" @click="addFakedns">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.fakedns.add') }}
</a-button>
</a-empty>
<template v-else>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-button type="primary" @click="addFakedns">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.fakedns.add') }}
</a-button>
<a-table :columns="fakednsColumns" :data-source="fakeDnsList" :row-key="(r) => r.key" :pagination="false"
size="small" bordered>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<a-space :size="6">
<span class="row-index">{{ index + 1 }}</span>
<a-button shape="circle" size="small" danger @click="deleteFakedns(index)">
<DeleteOutlined />
</a-button>
</a-space>
</template>
<template v-else-if="column.key === 'ipPool'">
<a-input :value="record.ipPool" size="small"
@change="(e) => updateFakednsField(index, 'ipPool', e.target.value)" />
</template>
<template v-else-if="column.key === 'poolSize'">
<a-input-number :value="record.poolSize" :min="1" size="small"
@change="(v) => updateFakednsField(index, 'poolSize', v)" />
</template>
</template>
</a-table>
</a-space>
</template>
</a-collapse-panel>
</a-collapse>
<DnsServerModal v-model:open="serverModalOpen" :server="editingServer" :is-edit="editingIndex != null"
@confirm="onServerConfirm" />
<DnsPresetsModal v-model:open="presetsModalOpen" @install="onPresetInstall" />
</template>
<style scoped>
.row-index {
font-weight: 500;
opacity: 0.7;
}
.muted {
opacity: 0.7;
word-break: break-all;
}
.danger {
color: #ff4d4f;
}
.hosts-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
.nord-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.nord-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.row-odd {
background: rgba(0, 0, 0, 0.03);
}
body.dark .row-odd {
background: rgba(255, 255, 255, 0.04);
}
.zero-margin {
margin: 0;
}
.mt-8 {
margin-top: 8px;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.my-10 {
margin: 10px 0;
}
.ml-8 {
margin-left: 8px;
}
.server-row {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
}
.server-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.server-load-tag {
margin-right: 0;
flex-shrink: 0;
}
+396
View File
@@ -0,0 +1,396 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import './NordModal.css';
interface NordModalProps {
open: boolean;
templateSettings: { outbounds?: { tag?: string }[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag: string }) => void;
onRemoveOutbound: (index: number) => void;
onRemoveRoutingRules: (payload: { prefix: string }) => void;
}
interface NordData {
token?: string;
private_key?: string;
}
interface Country {
id: number;
name: string;
code: string;
}
interface City {
id: number;
name: string;
}
interface NordServer {
id: number;
name: string;
hostname: string;
station: string;
load: number;
technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
location_ids?: number[];
cityId?: number | null;
cityName?: string;
}
function loadColor(load: number): string {
if (load < 30) return 'green';
if (load < 70) return 'orange';
return 'red';
}
export default function NordModal({
open,
templateSettings,
onClose,
onAddOutbound,
onResetOutbound,
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
const [token, setToken] = useState('');
const [manualKey, setManualKey] = useState('');
const [countries, setCountries] = useState<Country[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [servers, setServers] = useState<NordServer[]>([]);
const [countryId, setCountryId] = useState<number | null>(null);
const [cityId, setCityId] = useState<number | null>(null);
const [serverId, setServerId] = useState<number | null>(null);
const nordOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
}, [templateSettings?.outbounds]);
const filteredServers = useMemo(() => {
if (!cityId) return servers;
return servers.filter((s) => s.cityId === cityId);
}, [cityId, servers]);
useEffect(() => {
setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post('/panel/xray/nord/countries');
if (msg?.success) setCountries(JSON.parse(msg.obj));
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/nord/data');
if (msg?.success) {
const next = msg.obj ? JSON.parse(msg.obj) : null;
setNordData(next);
if (next) await fetchCountries();
}
} finally {
setLoading(false);
}
}, [fetchCountries]);
useEffect(() => {
if (open) fetchData();
}, [open, fetchData]);
async function login() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token });
if (msg?.success) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
}
} finally {
setLoading(false);
}
}
async function saveKey() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey });
if (msg?.success) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
}
} finally {
setLoading(false);
}
}
async function logout() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/nord/del');
if (msg?.success) {
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null);
setToken('');
setManualKey('');
setCountries([]);
setCities([]);
setServers([]);
setCountryId(null);
setCityId(null);
setServerId(null);
}
} finally {
setLoading(false);
}
}
async function fetchServers(newCountryId: number) {
setCountryId(newCountryId);
setLoading(true);
setServers([]);
setCities([]);
setServerId(null);
setCityId(null);
try {
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: newCountryId });
if (!msg?.success) return;
const data = JSON.parse(msg.obj);
const locations = data.locations || [];
const locToCity: Record<number, City> = {};
const citiesMap = new Map<number, City>();
for (const loc of locations) {
if (loc.country?.city) {
citiesMap.set(loc.country.city.id, loc.country.city);
locToCity[loc.id] = loc.country.city;
}
}
setCities(Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name)));
const next: NordServer[] = (data.servers || [])
.map((s: NordServer) => {
const firstLocId = (s.location_ids || [])[0];
const city = firstLocId != null ? locToCity[firstLocId] : null;
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
setServers(next);
if (next.length === 0) messageApi.warning('No servers found for the selected country');
} finally {
setLoading(false);
}
}
function buildNordOutbound(): Record<string, unknown> | null {
const server = servers.find((s) => s.id === serverId);
if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) {
messageApi.error('Selected server does not advertise a NordLynx public key.');
return null;
}
return {
tag: `nord-${server.hostname}`,
protocol: 'wireguard',
settings: {
secretKey: nordData?.private_key,
address: ['10.5.0.2/32'],
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
noKernelTun: false,
},
};
}
function addOutbound() {
const ob = buildNordOutbound();
if (!ob) return;
onAddOutbound(ob);
messageApi.success('NordVPN outbound added');
onClose();
}
function resetOutbound() {
if (nordOutboundIndex === -1) return;
const ob = buildNordOutbound();
if (!ob) return;
const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
onResetOutbound({
index: nordOutboundIndex,
outbound: ob,
oldTag,
newTag: ob.tag as string,
});
messageApi.success('NordVPN outbound updated');
onClose();
}
return (
<>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
{nordData == null ? (
<Tabs
defaultActiveKey="token"
items={[
{
key: 'token',
label: 'Access token',
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form.Item label="Access token">
<Input
value={token}
placeholder="Access token"
onChange={(e) => setToken(e.target.value)}
/>
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
Login
</Button>
</Form.Item>
</Form>
),
},
{
key: 'key',
label: 'Private key',
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form.Item label="Private key">
<Input
value={manualKey}
placeholder="Private key"
onChange={(e) => setManualKey(e.target.value)}
/>
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
Save
</Button>
</Form.Item>
</Form>
),
},
]}
/>
) : (
<>
<table className="nord-data-table">
<tbody>
{nordData.token && (
<tr className="row-odd">
<td>Access token</td>
<td>{nordData.token}</td>
</tr>
)}
<tr>
<td>Private key</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
Logout
</Button>
<Divider className="zero-margin">Settings</Divider>
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
<Form.Item label="Country">
<Select
value={countryId ?? undefined}
showSearch
optionFilterProp="label"
onChange={(v) => fetchServers(v)}
options={countries.map((c) => ({
value: c.id,
label: `${c.name} (${c.code})`,
}))}
/>
</Form.Item>
{cities.length > 0 && (
<Form.Item label="City">
<Select
value={cityId}
showSearch
optionFilterProp="label"
onChange={setCityId}
options={[{ value: null, label: 'All cities' }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
/>
</Form.Item>
)}
{filteredServers.length > 0 && (
<Form.Item label="Server">
<Select
value={serverId}
showSearch
optionFilterProp="label"
onChange={setServerId}
options={filteredServers.map((s) => ({
value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`,
children: (
<span className="server-row">
<span className="server-name">
{s.cityName} - {s.name}
</span>
<Tag color={loadColor(s.load)} className="server-load-tag">
{s.load}%
</Tag>
</span>
),
}))}
/>
</Form.Item>
)}
</Form>
<Divider className="my-10">Outbound status</Divider>
{nordOutboundIndex >= 0 ? (
<>
<Tag color="green">Enabled</Tag>
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
Reset
</Button>
</>
) : (
<>
<Tag color="orange">Disabled</Tag>
<Button
type="primary"
className="ml-8"
disabled={!serverId}
loading={loading}
onClick={addOutbound}
>
Add outbound
</Button>
</>
)}
</>
)}
</Modal>
</>
);
}
-407
View File
@@ -1,407 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { HttpUtil } from '@/utils';
// NordVPN provisioning modal — mirrors the legacy nord_modal.
//
// Login routes:
// • access token (NordVPN account) → /panel/xray/nord/reg
// • manual private key (existing wireguard key from NordLynx) →
// /panel/xray/nord/setKey
// Once authenticated, the country / city / server selectors fetch
// from /panel/xray/nord/{countries,servers}, and the user can stage
// a wireguard outbound (tag `nord-<hostname>`) for the parent's
// outbound list.
const props = defineProps({
open: { type: Boolean, default: false },
templateSettings: { type: Object, default: null },
});
const emit = defineEmits([
'update:open',
'add-outbound',
'reset-outbound',
'remove-outbound',
// Routing rules referencing the deleted nord-* outbound need the
// parent to clean them up — we emit, the parent purges.
'remove-routing-rules',
]);
const loading = ref(false);
const nordData = ref(null);
const token = ref('');
const manualKey = ref('');
const countries = ref([]);
const cities = ref([]);
const servers = ref([]);
const countryId = ref(null);
const cityId = ref(null);
const serverId = ref(null);
const nordOutboundIndex = computed(() => {
const list = props.templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
});
const filteredServers = computed(() => {
if (!cityId.value) return servers.value;
return servers.value.filter((s) => s.cityId === cityId.value);
});
watch(() => props.open, (next) => {
if (next) fetchData();
});
watch(() => filteredServers.value, (list) => {
// Auto-select the first server in the visible list (lowest load
// because servers were sorted ascending by load on fetch).
serverId.value = list.length > 0 ? list[0].id : null;
});
// === API actions ====================================================
async function fetchData() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/nord/data');
if (msg?.success) {
nordData.value = msg.obj ? JSON.parse(msg.obj) : null;
if (nordData.value) await fetchCountries();
}
} finally {
loading.value = false;
}
}
async function login() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: token.value });
if (msg?.success) {
nordData.value = JSON.parse(msg.obj);
await fetchCountries();
}
} finally {
loading.value = false;
}
}
async function saveKey() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey.value });
if (msg?.success) {
nordData.value = JSON.parse(msg.obj);
await fetchCountries();
}
} finally {
loading.value = false;
}
}
async function logout() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/nord/del');
if (msg?.success) {
// Clean up the staged outbound + matching routing rules first
// so a re-login doesn't carry stale references.
emit('remove-outbound', nordOutboundIndex.value);
emit('remove-routing-rules', { prefix: 'nord-' });
nordData.value = null;
token.value = '';
manualKey.value = '';
countries.value = [];
cities.value = [];
servers.value = [];
countryId.value = null;
cityId.value = null;
serverId.value = null;
}
} finally {
loading.value = false;
}
}
async function fetchCountries() {
const msg = await HttpUtil.post('/panel/xray/nord/countries');
if (msg?.success) countries.value = JSON.parse(msg.obj);
}
async function fetchServers() {
if (!countryId.value) return;
loading.value = true;
servers.value = [];
cities.value = [];
serverId.value = null;
cityId.value = null;
try {
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: countryId.value });
if (!msg?.success) return;
const data = JSON.parse(msg.obj);
const locations = data.locations || [];
const locToCity = {};
const citiesMap = new Map();
for (const loc of locations) {
if (loc.country?.city) {
citiesMap.set(loc.country.city.id, loc.country.city);
locToCity[loc.id] = loc.country.city;
}
}
cities.value = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
servers.value = (data.servers || [])
.map((s) => {
const firstLocId = (s.location_ids || [])[0];
const city = locToCity[firstLocId];
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
})
.sort((a, b) => a.load - b.load);
if (servers.value.length === 0) {
message.warning('No servers found for the selected country');
}
} finally {
loading.value = false;
}
}
// === Outbound staging ==============================================
// NordVPN exposes its WireGuard public key via a "technologies"
// array entry with id 35; the legacy modal pulls the key from the
// metadata field of that entry. Same here.
function buildNordOutbound() {
const server = servers.value.find((s) => s.id === serverId.value);
if (!server) return null;
const tech = server.technologies?.find((t) => t.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) {
message.error('Selected server does not advertise a NordLynx public key.');
return null;
}
return {
tag: `nord-${server.hostname}`,
protocol: 'wireguard',
settings: {
secretKey: nordData.value.private_key,
address: ['10.5.0.2/32'],
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
noKernelTun: false,
},
};
}
function addOutbound() {
const ob = buildNordOutbound();
if (!ob) return;
emit('add-outbound', ob);
message.success('NordVPN outbound added');
close();
}
function resetOutbound() {
if (nordOutboundIndex.value === -1) return;
const ob = buildNordOutbound();
if (!ob) return;
// Tag rename across routing.rules is the parent's job — pass
// both old and new tag in the payload.
const oldTag = props.templateSettings.outbounds[nordOutboundIndex.value]?.tag;
emit('reset-outbound', {
index: nordOutboundIndex.value,
outbound: ob,
oldTag,
newTag: ob.tag,
});
message.success('NordVPN outbound updated');
close();
}
function close() { emit('update:open', false); }
function loadColor(load) {
if (load < 30) return 'green';
if (load < 70) return 'orange';
return 'red';
}
</script>
<template>
<a-modal :open="open" title="NordVPN NordLynx" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
<!-- WARP / NordVPN provisioning forms keep technical wire labels in
English on purpose: they map directly to API field names users
look up in vendor docs. Only the primary action buttons +
dialog headers translate. -->
<!-- Not authenticated → tabbed login (token or manual key) -->
<template v-if="nordData == null">
<a-tabs default-active-key="token">
<a-tab-pane key="token" tab="Access token">
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
<a-form-item label="Access token">
<a-input v-model:value="token" placeholder="Access token" />
<a-button type="primary" class="mt-10" :loading="loading" @click="login">
<template #icon>
<LoginOutlined />
</template>
Login
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="key" tab="Private key">
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
<a-form-item label="Private key">
<a-input v-model:value="manualKey" placeholder="Private key" />
<a-button type="primary" class="mt-10" :loading="loading" @click="saveKey">
<template #icon>
<SaveOutlined />
</template>
Save
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</template>
<!-- Authenticated → server picker + outbound controls -->
<template v-else>
<table class="nord-data-table">
<tbody>
<tr v-if="nordData.token" class="row-odd">
<td>Access token</td>
<td>{{ nordData.token }}</td>
</tr>
<tr>
<td>Private key</td>
<td>{{ nordData.private_key }}</td>
</tr>
</tbody>
</table>
<a-button :loading="loading" type="primary" danger class="mt-8" @click="logout">Logout</a-button>
<a-divider class="zero-margin">Settings</a-divider>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-10">
<a-form-item label="Country">
<a-select v-model:value="countryId" show-search option-filter-prop="label" @change="fetchServers">
<a-select-option v-for="c in countries" :key="c.id" :value="c.id" :label="c.name">
{{ c.name }} ({{ c.code }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="cities.length > 0" label="City">
<a-select v-model:value="cityId" show-search option-filter-prop="label">
<a-select-option :value="null" label="All cities">All cities</a-select-option>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id" :label="c.name">{{ c.name
}}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="filteredServers.length > 0" label="Server">
<a-select v-model:value="serverId" show-search option-filter-prop="label">
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id"
:label="`${s.cityName} ${s.name} ${s.hostname}`">
<span class="server-row">
<span class="server-name">{{ s.cityName }} - {{ s.name }}</span>
<a-tag :color="loadColor(s.load)" class="server-load-tag">{{ s.load }}%</a-tag>
</span>
</a-select-option>
</a-select>
</a-form-item>
</a-form>
<a-divider class="my-10">Outbound status</a-divider>
<template v-if="nordOutboundIndex >= 0">
<a-tag color="green">Enabled</a-tag>
<a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
Reset
</a-button>
</template>
<template v-else>
<a-tag color="orange">Disabled</a-tag>
<a-button type="primary" class="ml-8" :disabled="!serverId" :loading="loading" @click="addOutbound">Add
outbound</a-button>
</template>
</template>
</a-modal>
</template>
<style scoped>
.nord-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.nord-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.row-odd {
background: rgba(0, 0, 0, 0.03);
}
:global(body.dark) .row-odd {
background: rgba(255, 255, 255, 0.04);
}
.zero-margin {
margin: 0;
}
.mt-8 {
margin-top: 8px;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.my-10 {
margin: 10px 0;
}
.ml-8 {
margin-left: 8px;
}
.server-row {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
}
.server-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.server-load-tag {
margin-right: 0;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,38 @@
.random-icon {
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
margin-left: 4px;
}
.danger-icon {
cursor: pointer;
color: #ff4d4f;
margin-left: 8px;
}
.ml-8 {
margin-left: 8px;
}
.mb-8 {
margin-bottom: 8px;
}
.item-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 500;
opacity: 0.85;
}
.sniffing-options {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.sniffing-options .ant-checkbox-wrapper {
margin-inline-start: 0;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
.toolbar-right {
display: flex;
justify-content: flex-end;
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 16px 0;
}
.outbound-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.card-identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.card-num {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.tag-name {
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.protocol-line {
display: inline-flex;
flex-wrap: wrap;
gap: 2px;
}
.address-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.address-pill {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.05);
}
body.dark .address-pill {
background: rgba(255, 255, 255, 0.06);
}
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.row-index {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.identity-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.card-foot {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-test {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.traffic-up {
color: #008771;
font-size: 12px;
}
.traffic-down {
color: #3c89e8;
font-size: 12px;
}
.traffic-sep {
display: inline-block;
width: 4px;
}
.pill-ok,
.pill-fail {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 12px;
font-size: 12px;
}
.pill-ok {
color: #008771;
background: rgba(0, 135, 113, 0.12);
}
.pill-fail {
color: #e04141;
background: rgba(224, 65, 65, 0.12);
}
.empty {
opacity: 0.4;
}
.outbound-test-popover .timing-breakdown {
font-size: 12px;
line-height: 1.6;
min-width: 180px;
max-width: 320px;
}
.outbound-test-popover .td-head {
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.outbound-test-popover .td-head.ok {
color: #008771;
}
.outbound-test-popover .td-head.fail {
color: #e04141;
}
.outbound-test-popover .mode-badge {
font-size: 10px;
font-weight: 500;
padding: 0 6px;
border-radius: 8px;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
margin-left: auto;
}
.outbound-test-popover .endpoint-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .endpoint-row .ep-addr {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.outbound-test-popover .endpoint-row .ep-meta {
opacity: 0.75;
}
.outbound-test-popover .dot-ok {
color: #008771;
}
.outbound-test-popover .dot-fail {
color: #e04141;
}
+520
View File
@@ -0,0 +1,520 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Col,
Dropdown,
Modal,
Popconfirm,
Popover,
Radio,
Row,
Space,
Table,
Tag,
Tooltip,
} from 'antd';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { Protocols } from '@/models/outbound.js';
import OutboundFormModal from './OutboundFormModal';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
testingAll: boolean;
inboundTags: string[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
onTestAll: (mode: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
}
interface OutboundRow {
key: number;
tag?: string;
protocol?: string;
streamSettings?: { network?: string; security?: string };
settings?: Record<string, unknown>;
}
function outboundAddresses(o: OutboundRow): string[] {
const settings = o.settings as Record<string, unknown> | undefined;
switch (o.protocol) {
case Protocols.VMess: {
const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.VLESS:
return [`${settings?.address || ''}:${settings?.port || ''}`];
case Protocols.HTTP:
case Protocols.Socks:
case Protocols.Shadowsocks:
case Protocols.Trojan: {
const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.DNS: {
const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
const port = (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
return addr || port ? [`${addr}:${port}`] : [];
}
case Protocols.Wireguard:
return (((settings?.peers as Array<{ endpoint?: string }>) || []).map((p) => p.endpoint || '').filter(Boolean));
default:
return [];
}
}
function isUntestable(o: OutboundRow, mode: string): boolean {
if (!o) return true;
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
return false;
}
function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality';
}
function hasBreakdown(r: { endpoints?: unknown[]; ttfbMs?: number; tlsMs?: number; connectMs?: number; dnsMs?: number; statusCode?: number; error?: string } | null | undefined): boolean {
if (!r) return false;
if (r.endpoints?.length) return true;
return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
}
export default function OutboundsTab({
templateSettings,
setTemplateSettings,
outboundsTraffic,
outboundTestStates,
testingAll,
inboundTags: _inboundTags,
isMobile,
onResetTraffic,
onTest,
onTestAll,
onShowWarp,
onShowNord,
}: OutboundsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as OutboundRow[],
[templateSettings?.outbounds],
);
const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function openAdd() {
setEditingOutbound(null);
setEditingIndex(null);
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
setModalOpen(true);
}
function openEdit(idx: number) {
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
setEditingIndex(idx);
setExistingTags(
(templateSettings?.outbounds || [])
.filter((_, i) => i !== idx)
.map((o) => o?.tag)
.filter((tg): tg is string => !!tg),
);
setModalOpen(true);
}
function onConfirm(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
if (editingIndex == null) {
if (!outbound.tag) return;
tt.outbounds.push(outbound as never);
} else {
tt.outbounds[editingIndex] = outbound as never;
}
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => {
mutate((tt) => {
tt.outbounds?.splice(idx, 1);
});
},
});
}
function setFirst(idx: number) {
mutate((tt) => {
if (!tt.outbounds) return;
const [moved] = tt.outbounds.splice(idx, 1);
tt.outbounds.unshift(moved);
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
mutate((tt) => {
if (!tt.outbounds) return;
[tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]];
});
}
function moveDown(idx: number) {
mutate((tt) => {
if (!tt.outbounds || idx >= tt.outbounds.length - 1) return;
[tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]];
});
}
function trafficFor(o: OutboundRow): { up: number; down: number } {
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
return { up: tr?.up || 0, down: tr?.down || 0 };
}
function isTesting(idx: number): boolean {
return !!outboundTestStates?.[idx]?.testing;
}
function testResult(idx: number) {
return outboundTestStates?.[idx]?.result || null;
}
const columns: ColumnsType<OutboundRow> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className="action-buttons">
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{ key: 'top', label: <><VerticalAlignTopOutlined /> Move to top</>, onClick: () => setFirst(index) },
]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'reset', label: <><RetweetOutlined /> Reset traffic</>, onClick: () => onResetTraffic(rows[index].tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> Delete</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: 'Tag',
key: 'identity',
align: 'left',
render: (_v, record) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
</div>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty">—</span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'left',
width: 200,
render: (_v, record) => {
const tr = trafficFor(record);
return (
<>
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
},
},
{
title: 'Latency',
key: 'testResult',
align: 'left',
width: 140,
render: (_v, _record, index) => {
const r = testResult(index);
if (!r) return isTesting(index) ? <LoadingOutlined /> : <span className="empty">—</span>;
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{r.ttfbMs ? <div>TTFB: {r.ttfbMs} ms</div> : null}
{r.tlsMs ? <div>TLS: {r.tlsMs} ms</div> : null}
{r.connectMs ? <div>Connect: {r.connectMs} ms</div> : null}
{r.dnsMs ? <div>DNS: {r.dnsMs} ms</div> : null}
{r.statusCode ? <div>HTTP {r.statusCode}</div> : null}
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
},
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record, index) => (
<Tooltip title={`${t('check')} (${testMode.toUpperCase()})`}>
<Button
type="primary"
shape="circle"
loading={isTesting(index)}
disabled={isUntestable(record, testMode) || isTesting(index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
</Tooltip>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, testMode, rows.length, outboundTestStates, outboundsTraffic],
);
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
<Space size="small" wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
WARP
</Button>
<Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
NordVPN
</Button>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Tooltip title="TCP: fast dial-only probe. HTTP: full request through xray.">
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
</Radio.Group>
</Tooltip>
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
{!isMobile && 'Test all'}
</Button>
<Popconfirm
placement="topRight"
okText={t('reset')}
cancelText={t('cancel')}
title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')}
>
<Button icon={<RetweetOutlined />} />
</Popconfirm>
</Space>
</Col>
</Row>
{isMobile ? (
rows.length === 0 ? (
<div className="card-empty">—</div>
) : (
rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [{ key: 'top', label: <VerticalAlignTopOutlined />, onClick: () => setFirst(index) }]
: []),
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
{outboundAddresses(record).length > 0 && (
<div className="address-list">
{outboundAddresses(record).map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))}
</div>
)}
<div className="card-foot">
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(record).up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(record).down)}</span>
<span className="card-test">
{testResult(index) ? (
<span className={testResult(index)!.success ? 'pill-ok' : 'pill-fail'}>
{testResult(index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{testResult(index)!.success ? <span>{testResult(index)!.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
) : isTesting(index) ? (
<LoadingOutlined />
) : null}
<Button
type="primary"
shape="circle"
size="small"
loading={isTesting(index)}
disabled={isUntestable(record, testMode) || isTesting(index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
</span>
</div>
</div>
))
)
) : (
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
/>
)}
<OutboundFormModal
open={modalOpen}
outbound={editingOutbound}
existingTags={existingTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</Space>
</>
);
}
-676
View File
@@ -1,676 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
PlayCircleOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import { SizeFormatter } from '@/utils';
import { Protocols } from '@/models/outbound.js';
import OutboundFormModal from './OutboundFormModal.vue';
const { t } = useI18n();
const props = defineProps({
templateSettings: { type: Object, default: null },
outboundsTraffic: { type: Array, default: () => [] },
outboundTestStates: { type: Object, default: () => ({}) },
testingAll: { type: Boolean, default: false },
inboundTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false },
});
const emit = defineEmits(['reset-traffic', 'test', 'test-all', 'show-warp', 'show-nord', 'delete']);
const testMode = ref('tcp');
// === Modal state ====================================================
const modalOpen = ref(false);
const editingOutbound = ref(null);
const editingIndex = ref(null);
const existingTags = ref([]);
function openAdd() {
editingOutbound.value = null;
editingIndex.value = null;
existingTags.value = (props.templateSettings?.outbounds || []).map((o) => o.tag);
modalOpen.value = true;
}
function openEdit(idx) {
editingOutbound.value = props.templateSettings.outbounds[idx];
editingIndex.value = idx;
existingTags.value = (props.templateSettings?.outbounds || [])
.filter((_, i) => i !== idx)
.map((o) => o.tag);
modalOpen.value = true;
}
function onConfirm(outbound) {
if (editingIndex.value == null) {
if (!outbound.tag) return;
props.templateSettings.outbounds.push(outbound);
} else {
props.templateSettings.outbounds[editingIndex.value] = outbound;
}
modalOpen.value = false;
}
function confirmDelete(idx) {
Modal.confirm({
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => { emit('delete', idx); },
});
}
function setFirst(idx) {
const arr = props.templateSettings.outbounds;
arr.unshift(arr.splice(idx, 1)[0]);
}
function moveUp(idx) {
if (idx <= 0) return;
const arr = props.templateSettings.outbounds;
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
}
function moveDown(idx) {
const arr = props.templateSettings.outbounds;
if (idx >= arr.length - 1) return;
[arr[idx + 1], arr[idx]] = [arr[idx], arr[idx + 1]];
}
// === Per-row helpers ================================================
function trafficFor(o) {
const t = props.outboundsTraffic.find((x) => x.tag === o.tag);
return { up: t?.up || 0, down: t?.down || 0 };
}
// Lifted from legacy findOutboundAddress — returns an array of
// "host:port" strings for the protocols that have one, or null when
// the outbound has no externally-visible endpoint (Freedom, Blackhole,
// DNS without an explicit address, etc.).
function outboundAddresses(o) {
let serverObj;
switch (o.protocol) {
case Protocols.VMess:
serverObj = o.settings?.vnext;
break;
case Protocols.VLESS:
return [`${o.settings?.address || ''}:${o.settings?.port || ''}`];
case Protocols.HTTP:
case Protocols.Socks:
case Protocols.Shadowsocks:
case Protocols.Trojan:
serverObj = o.settings?.servers;
break;
case Protocols.DNS: {
const addr = o.settings?.rewriteAddress || o.settings?.address || '';
const port = o.settings?.rewritePort || o.settings?.port || '';
return addr || port ? [`${addr}:${port}`] : [];
}
case Protocols.Wireguard:
return (o.settings?.peers || []).map((p) => p.endpoint);
default:
return [];
}
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
function isUntestable(o, mode = testMode.value) {
if (!o) return true;
if (o.protocol === Protocols.Blackhole
|| o.protocol === Protocols.Loopback
|| o.tag === 'blocked') return true;
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
return false;
}
function isTesting(idx) {
return !!props.outboundTestStates?.[idx]?.testing;
}
function testResult(idx) {
return props.outboundTestStates?.[idx]?.result || null;
}
function showSecurity(security) {
return security === 'tls' || security === 'reality';
}
function hasBreakdown(r) {
if (!r) return false;
if (r.endpoints?.length) return true;
return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
}
// === Columns ========================================================
// Computed so titles re-render after a locale swap.
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 100 },
{ title: 'Tag', key: 'identity', align: 'left' },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left' },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
{ title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
{ title: t('check'), key: 'test', align: 'center', width: 80 },
]);
const rows = computed(() => {
if (!props.templateSettings?.outbounds) return [];
return props.templateSettings.outbounds.map((o, i) => ({ key: i, ...o }));
});
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<!-- Toolbar -->
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
<a-col :xs="24" :sm="12">
<a-space size="small" wrap>
<a-button type="primary" @click="openAdd">
<template #icon>
<PlusOutlined />
</template>
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
</a-button>
<a-button type="primary" @click="emit('show-warp')">
<template #icon>
<CloudOutlined />
</template>
WARP
</a-button>
<a-button type="primary" @click="emit('show-nord')">
<template #icon>
<ApiOutlined />
</template>
NordVPN
</a-button>
</a-space>
</a-col>
<a-col :xs="24" :sm="12" class="toolbar-right">
<a-space size="small" wrap>
<a-tooltip :title="t('pages.xray.testModeHint') !== 'pages.xray.testModeHint' ? t('pages.xray.testModeHint') : 'TCP: fast dial-only probe. HTTP: full request through xray.'">
<a-radio-group v-model:value="testMode" size="small" button-style="solid">
<a-radio-button value="tcp">TCP</a-radio-button>
<a-radio-button value="http">HTTP</a-radio-button>
</a-radio-group>
</a-tooltip>
<a-button type="primary" :loading="testingAll" @click="emit('test-all', testMode)">
<template #icon>
<PlayCircleOutlined />
</template>
<span v-if="!isMobile">{{ t('pages.xray.testAll') !== 'pages.xray.testAll' ? t('pages.xray.testAll') : 'Test all' }}</span>
</a-button>
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
<a-button>
<template #icon>
<RetweetOutlined />
</template>
</a-button>
</a-popconfirm>
</a-space>
</a-col>
</a-row>
<!-- Mobile: card list -->
<template v-if="isMobile">
<div v-if="rows.length === 0" class="card-empty">—</div>
<div v-for="(record, index) in rows" :key="record.key" class="outbound-card">
<div class="card-head">
<div class="card-identity">
<span class="card-num">{{ index + 1 }}</span>
<a-tooltip :title="record.tag">
<span class="tag-name">{{ record.tag }}</span>
</a-tooltip>
<a-tag color="green">{{ record.protocol }}</a-tag>
<template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)">
<a-tag>{{ record.streamSettings?.network }}</a-tag>
<a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
{{ record.streamSettings.security }}
</a-tag>
</template>
</div>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined />
</a-menu-item>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-if="outboundAddresses(record).length > 0" class="address-list">
<a-tooltip v-for="addr in outboundAddresses(record)" :key="addr" :title="addr">
<span class="address-pill">{{ addr }}</span>
</a-tooltip>
</div>
<div class="card-foot">
<span class="traffic-up">↑ {{ SizeFormatter.sizeFormat(trafficFor(record).up) }}</span>
<span class="traffic-sep" />
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
<span class="card-test">
<a-popover v-if="testResult(index)" placement="topRight"
:overlay-class-name="'outbound-test-popover'">
<template #content>
<div class="timing-breakdown">
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
<span v-else>{{ testResult(index).error || 'failed' }}</span>
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
</div>
<template v-if="hasBreakdown(testResult(index))">
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
<span class="ep-addr">{{ ep.address }}</span>
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
</div>
</template>
</div>
</template>
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
<CheckCircleFilled v-if="testResult(index).success" />
<CloseCircleFilled v-else />
<span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
<span v-else>failed</span>
</span>
</a-popover>
<LoadingOutlined v-else-if="isTesting(index)" />
<a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
<template #icon>
<ThunderboltOutlined />
</template>
</a-button>
</span>
</div>
</div>
</template>
<!-- Desktop: table -->
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<div class="action-cell">
<span class="row-index">{{ index + 1 }}</span>
<div class="action-buttons">
<a-button shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined /> Move to top
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</template>
<template v-else-if="column.key === 'identity'">
<div class="identity-cell">
<a-tooltip :title="record.tag">
<span class="tag-name">{{ record.tag }}</span>
</a-tooltip>
<div class="protocol-line">
<a-tag color="green">{{ record.protocol }}</a-tag>
<template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)">
<a-tag>{{ record.streamSettings?.network }}</a-tag>
<a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
{{ record.streamSettings.security }}
</a-tag>
</template>
</div>
</div>
</template>
<template v-else-if="column.key === 'address'">
<div class="address-list">
<a-tooltip v-for="addr in outboundAddresses(record)" :key="addr" :title="addr">
<span class="address-pill">{{ addr }}</span>
</a-tooltip>
<span v-if="outboundAddresses(record).length === 0" class="empty">—</span>
</div>
</template>
<template v-else-if="column.key === 'traffic'">
<span class="traffic-up">↑ {{ SizeFormatter.sizeFormat(trafficFor(record).up) }}</span>
<span class="traffic-sep" />
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
</template>
<template v-else-if="column.key === 'testResult'">
<a-popover v-if="testResult(index)" placement="topLeft"
:overlay-class-name="'outbound-test-popover'">
<template #content>
<div class="timing-breakdown">
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
<span v-else>{{ testResult(index).error || 'failed' }}</span>
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
</div>
<template v-if="hasBreakdown(testResult(index))">
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
<span class="ep-addr">{{ ep.address }}</span>
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
</div>
</template>
</div>
</template>
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
<CheckCircleFilled v-if="testResult(index).success" />
<CloseCircleFilled v-else />
<span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
<span v-else>failed</span>
</span>
</a-popover>
<LoadingOutlined v-else-if="isTesting(index)" />
<span v-else class="empty">—</span>
</template>
<template v-else-if="column.key === 'test'">
<a-tooltip :title="`${t('check')} (${testMode.toUpperCase()})`">
<a-button type="primary" shape="circle" :loading="isTesting(index)"
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
<template #icon>
<ThunderboltOutlined />
</template>
</a-button>
</a-tooltip>
</template>
</template>
</a-table>
<OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
@confirm="onConfirm" />
</a-space>
</template>
<style scoped>
.toolbar-right {
display: flex;
justify-content: flex-end;
}
.toolbar-right :global(.ant-space),
.header-actions :global(.ant-space) {
margin-bottom: 0 !important;
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 16px 0;
}
.outbound-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.card-identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.card-num {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.tag-name {
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.protocol-line {
display: inline-flex;
flex-wrap: wrap;
gap: 2px;
}
.address-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.address-pill {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.05);
}
:global(body.dark) .address-pill {
background: rgba(255, 255, 255, 0.06);
}
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.row-index {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.identity-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.card-foot {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-test {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.traffic-up {
color: #008771;
font-size: 12px;
}
.traffic-down {
color: #3c89e8;
font-size: 12px;
}
.traffic-sep {
display: inline-block;
width: 4px;
}
.pill-ok,
.pill-fail {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 12px;
font-size: 12px;
}
.pill-ok {
color: #008771;
background: rgba(0, 135, 113, 0.12);
}
.pill-fail {
color: #e04141;
background: rgba(224, 65, 65, 0.12);
}
.empty {
opacity: 0.4;
}
.danger {
color: #ff4d4f;
}
</style>
<style>
.outbound-test-popover .timing-breakdown {
font-size: 12px;
line-height: 1.6;
min-width: 180px;
max-width: 320px;
}
.outbound-test-popover .td-head {
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.outbound-test-popover .td-head.ok {
color: #008771;
}
.outbound-test-popover .td-head.fail {
color: #e04141;
}
.outbound-test-popover .mode-badge {
font-size: 10px;
font-weight: 500;
padding: 0 6px;
border-radius: 8px;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
margin-left: auto;
}
.outbound-test-popover .endpoint-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .endpoint-row .ep-addr {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.outbound-test-popover .endpoint-row .ep-meta {
opacity: 0.75;
}
.outbound-test-popover .dot-ok {
color: #008771;
}
.outbound-test-popover .dot-fail {
color: #e04141;
}
</style>
+232
View File
@@ -0,0 +1,232 @@
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.drag-handle {
cursor: grab;
opacity: 0.35;
font-size: 14px;
padding: 4px;
margin: -4px;
touch-action: none;
transition: opacity 0.15s;
}
.drag-handle:hover {
opacity: 0.8;
}
.drag-handle:active {
cursor: grabbing;
}
.row-dragging {
opacity: 0.4;
}
.drop-before > td {
box-shadow: inset 0 2px 0 0 #1677ff;
}
.drop-after > td {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.row-index {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.criterion-flow {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
}
.criterion-row {
display: inline-flex;
align-items: baseline;
gap: 4px;
white-space: nowrap;
}
.criterion-label {
font-size: 10px;
text-transform: uppercase;
opacity: 0.55;
letter-spacing: 0.04em;
}
.criterion-value {
font-weight: 500;
}
.criterion-more {
font-size: 11px;
padding: 0 5px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.06);
}
body.dark .criterion-more {
background: rgba(255, 255, 255, 0.1);
}
.criterion-empty {
opacity: 0.4;
}
.target-row {
display: flex;
align-items: center;
gap: 4px;
}
.target-icon {
font-size: 12px;
opacity: 0.6;
}
.rule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-card, #fff);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 8px;
transition: opacity 0.15s, box-shadow 0.15s;
}
.rule-card.row-dragging {
opacity: 0.4;
}
.rule-card.drop-before {
box-shadow: inset 0 2px 0 0 #1677ff;
}
.rule-card.drop-after {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.rule-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.rule-number {
font-weight: 600;
font-size: 13px;
opacity: 0.75;
flex: 1;
}
.rule-flow {
display: flex;
align-items: center;
gap: 8px;
}
.flow-side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.flow-side-target {
align-items: flex-end;
text-align: right;
}
.flow-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
}
.flow-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.flow-arrow {
font-size: 16px;
opacity: 0.45;
flex-shrink: 0;
}
.rule-criteria {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-top: 6px;
border-top: 1px dashed rgba(128, 128, 128, 0.2);
}
.criterion-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 1px 6px;
font-size: 11px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.criterion-chip-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
.criterion-chip-value {
font-weight: 500;
}
.rule-empty {
padding: 24px;
text-align: center;
opacity: 0.4;
}
body.dark .rule-card {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
body.dark .criterion-chip {
background: rgba(255, 255, 255, 0.06);
}
+546
View File
@@ -0,0 +1,546 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Modal, Space, Table, Tag, Tooltip } from 'antd';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
ExportOutlined,
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './RoutingTab.css';
interface RoutingTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
inboundTags: string[];
clientReverseTags: string[];
isMobile: boolean;
}
interface RuleRow {
key: number;
domain?: string;
ip?: string;
port?: string;
sourcePort?: string;
vlessRoute?: string;
network?: string;
sourceIP?: string;
user?: string;
inboundTag?: string;
protocol?: string;
attrs?: string;
outboundTag?: string;
balancerTag?: string;
}
function arrJoin(v: unknown): string | undefined {
if (v == null) return undefined;
if (Array.isArray(v)) return v.join(',');
return String(v);
}
function csv(value?: string): string[] {
if (!value) return [];
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
}
function chipPreview(value?: string): string {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
export default function RoutingTab({
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
isMobile,
}: RoutingTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({
from: null,
to: null,
startY: 0,
moved: false,
});
const rules = useMemo(
() => (templateSettings?.routing?.rules || []) as RoutingRule[],
[templateSettings?.routing?.rules],
);
const rows: RuleRow[] = useMemo(
() =>
rules.map((rule, idx) => {
const r: RuleRow = { key: idx };
r.domain = arrJoin(rule.domain);
r.ip = arrJoin(rule.ip);
r.port = rule.port;
r.sourcePort = rule.sourcePort;
r.vlessRoute = rule.vlessRoute;
r.network = rule.network;
r.sourceIP = arrJoin(rule.sourceIP);
r.user = arrJoin(rule.user);
r.inboundTag = arrJoin(rule.inboundTag);
r.protocol = arrJoin(rule.protocol);
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
r.attrs = JSON.stringify(rule.attrs, null, 2);
}
r.outboundTag = rule.outboundTag;
r.balancerTag = rule.balancerTag;
return r;
}),
[rules],
);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const inboundTagOptions = useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
const push = (tag?: string) => {
if (!tag || seen.has(tag)) return;
seen.add(tag);
out.push(tag);
};
for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag);
for (const tag of inboundTags || []) push(tag);
for (const ob of templateSettings?.outbounds || []) {
const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } };
push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag);
}
push((templateSettings?.dns as { tag?: string } | undefined)?.tag);
for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) {
if (typeof s === 'object' && s?.tag) push(s.tag);
}
return out;
}, [templateSettings, inboundTags]);
const outboundTagOptions = useMemo(() => {
const out = new Set<string>(['']);
for (const ob of templateSettings?.outbounds || []) {
if (ob?.tag) out.add(ob.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) out.add(tag);
}
return [...out];
}, [templateSettings?.outbounds, clientReverseTags]);
const balancerTagOptions = useMemo(() => {
const out: string[] = [''];
for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) {
if (b?.tag) out.push(b.tag);
}
return out;
}, [templateSettings?.routing?.balancers]);
function openAdd() {
setEditingRule(null);
setEditingIndex(null);
setRuleModalOpen(true);
}
function openEdit(idx: number) {
setEditingRule(rules[idx]);
setEditingIndex(idx);
setRuleModalOpen(true);
}
function onRuleConfirm(rule: Record<string, unknown>) {
if (JSON.stringify(rule).length <= 3) {
setRuleModalOpen(false);
return;
}
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [] };
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
if (editingIndex == null) tt.routing.rules.push(rule);
else tt.routing.rules[editingIndex] = rule;
});
setRuleModalOpen(false);
}
function confirmDelete(idx: number) {
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => mutate((tt) => {
tt.routing?.rules?.splice(idx, 1);
}),
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
});
}
function moveDown(idx: number) {
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || idx >= list.length - 1) return;
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
try {
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
} catch { /* ignore */ }
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
setDraggedIndex(idx);
setDropTargetIndex(idx);
const onMove = (e: PointerEvent) => {
const state = dragRef.current;
if (state.from == null) return;
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
state.moved = true;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const newIdx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(newIdx) && newIdx !== state.to) {
state.to = newIdx;
setDropTargetIndex(newIdx);
}
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
const { from, to, moved } = dragRef.current;
dragRef.current = { from: null, to: null, startY: 0, moved: false };
setDraggedIndex(null);
setDropTargetIndex(null);
if (!moved || from == null || to == null || from === to) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
const [movedItem] = list.splice(from, 1);
list.splice(to, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}
function ruleCriteriaChips(rule: RuleRow) {
const chips: { label: string; value?: string }[] = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
const desktopColumns: ColumnsType<RuleRow> = useMemo(
() => [
{
title: '#',
align: 'center',
width: 100,
key: 'action',
render: (_v, _r, index) => (
<div className="action-cell">
<HolderOutlined
className="drag-handle"
title="Drag to reorder"
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rows.length - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: 'Source',
align: 'left',
width: 180,
key: 'source',
render: (_v, record) => (
<div className="criterion-flow">
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
{record.sourcePort && <CriterionRow label="Port" value={record.sourcePort} title={`Source port: ${record.sourcePort}`} />}
{record.vlessRoute && <CriterionRow label="VLESS" value={record.vlessRoute} title={`VLESS route: ${record.vlessRoute}`} />}
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && <span className="criterion-empty">—</span>}
</div>
),
},
{
title: t('pages.inbounds.network'),
align: 'left',
width: 180,
key: 'network',
render: (_v, record) => (
<div className="criterion-flow">
{record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty">—</span>}
</div>
),
},
{
title: 'Destination',
align: 'left',
key: 'destination',
render: (_v, record) => (
<div className="criterion-flow">
{record.ip && <CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />}
{record.domain && <CriterionRow label="Domain" value={record.domain} title={`Domain: ${record.domain}`} />}
{record.port && <CriterionRow label="Port" value={record.port} title={`Destination port: ${record.port}`} />}
{!record.ip && !record.domain && !record.port && <span className="criterion-empty">—</span>}
</div>
),
},
{
title: t('pages.xray.Inbounds'),
align: 'left',
width: 180,
key: 'inbound',
render: (_v, record) => (
<div className="criterion-flow">
{record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
{record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
{!record.inboundTag && !record.user && <span className="criterion-empty">—</span>}
</div>
),
},
{
title: t('pages.xray.Outbounds'),
align: 'left',
width: 170,
key: 'outbound',
render: (_v, record) =>
record.outboundTag ? (
<div className="target-row">
<ExportOutlined className="target-icon" />
<Tag color="green">{record.outboundTag}</Tag>
</div>
) : (
<span className="criterion-empty">—</span>
),
},
{
title: t('pages.xray.Balancers'),
align: 'left',
width: 150,
key: 'balancer',
render: (_v, record) =>
record.balancerTag ? (
<div className="target-row">
<ClusterOutlined className="target-icon" />
<Tag color="purple">{record.balancerTag}</Tag>
</div>
) : (
<span className="criterion-empty">—</span>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, isMobile, rows.length],
);
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
{isMobile ? (
<div className="rule-list">
{rows.length === 0 ? (
<div className="rule-empty">—</div>
) : (
rows.map((rule, index) => (
<div
key={rule.key}
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
data-row-key={index}
>
<div className="rule-card-head">
<HolderOutlined
className="drag-handle"
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
/>
<span className="rule-number">#{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
<div className="rule-flow">
<div className="flow-side">
<span className="flow-label">{t('pages.xray.Inbounds')}</span>
{rule.inboundTag ? (
<Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
) : (
<span className="criterion-empty">any</span>
)}
</div>
<span className="flow-arrow">→</span>
<div className="flow-side flow-side-target">
<span className="flow-label">
{rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')}
</span>
{rule.outboundTag ? (
<Tag color="green" className="flow-tag">
<ExportOutlined /> {rule.outboundTag}
</Tag>
) : rule.balancerTag ? (
<Tag color="purple" className="flow-tag">
<ClusterOutlined /> {rule.balancerTag}
</Tag>
) : (
<span className="criterion-empty">—</span>
)}
</div>
</div>
{ruleCriteriaChips(rule).length > 0 && (
<div className="rule-criteria">
{ruleCriteriaChips(rule).map((chip) => (
<Tooltip key={chip.label} title={`${chip.label}: ${chip.value}`}>
<span className="criterion-chip">
<span className="criterion-chip-label">{chip.label}</span>
<span className="criterion-chip-value">{chipPreview(chip.value)}</span>
</span>
</Tooltip>
))}
</div>
)}
</div>
))
)}
</div>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: 1150 }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
}}
/>
)}
<RuleFormModal
open={ruleModalOpen}
rule={editingRule}
inboundTags={inboundTagOptions}
outboundTags={outboundTagOptions}
balancerTags={balancerTagOptions}
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
</Space>
</>
);
}
function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) {
const parts = csv(value);
if (parts.length === 0) return null;
return (
<Tooltip title={title}>
<span className="criterion-row">
<span className="criterion-label">{label}</span>
<span className="criterion-value">{parts[0]}</span>
{parts.length > 1 && <span className="criterion-more">+{parts.length - 1}</span>}
</span>
</Tooltip>
);
}
-764
View File
@@ -1,764 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
ExportOutlined,
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import RuleFormModal from './RuleFormModal.vue';
const { t } = useI18n();
// Routing tab — table over templateSettings.routing.rules with the
// modernised legacy column layout. Each row is rendered as a single
// "lead value + N more" pill per criterion (matches the legacy pill
// layout); full lists surface via tooltip on hover.
//
// Reorder via Pointer Events on the grip icon — unified mouse +
// touch + pen path so the same code works on desktop and mobile
// (HTML5 drag doesn't fire from touch on iOS Safari, hence the
// switch). Up/down buttons in the action menu stay as a keyboard
// fallback. The mobile column layout drops source/network/
// destination criteria for readability.
const props = defineProps({
templateSettings: { type: Object, default: null },
inboundTags: { type: Array, default: () => [] },
clientReverseTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false },
});
// === Table data — match the legacy routingRuleData shape ============
// Convert array criteria to CSV strings so the pill renderer can
// split + summarise them without needing a separate path per shape.
const rows = computed(() => {
if (!props.templateSettings?.routing?.rules) return [];
return props.templateSettings.routing.rules.map((rule, idx) => {
const r = { key: idx, ...rule };
if (Array.isArray(r.domain)) r.domain = r.domain.join(',');
if (Array.isArray(r.ip)) r.ip = r.ip.join(',');
if (Array.isArray(r.source)) r.source = r.source.join(',');
if (Array.isArray(r.user)) r.user = r.user.join(',');
if (Array.isArray(r.inboundTag)) r.inboundTag = r.inboundTag.join(',');
if (Array.isArray(r.protocol)) r.protocol = r.protocol.join(',');
if (r.attrs && typeof r.attrs === 'object' && !Array.isArray(r.attrs)) {
r.attrs = JSON.stringify(r.attrs, null, 2);
}
return r;
});
});
function csv(value) {
if (!value) return [];
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
}
// === Modal state ====================================================
const ruleModalOpen = ref(false);
const editingRule = ref(null);
const editingIndex = ref(null);
const inboundTagOptions = computed(() => {
const seen = new Set();
const out = [];
function pushUnique(tag) {
if (!tag) return;
if (seen.has(tag)) return;
seen.add(tag);
out.push(tag);
}
for (const ib of props.templateSettings?.inbounds || []) {
pushUnique(ib.tag);
}
for (const t of props.inboundTags || []) {
pushUnique(t);
}
for (const ob of props.templateSettings?.outbounds || []) {
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag || ob?.settings?.inboundTag;
pushUnique(rt);
}
pushUnique(props.templateSettings?.dns?.tag);
for (const s of props.templateSettings?.dns?.servers || []) {
if (typeof s === 'object' && s?.tag) pushUnique(s.tag);
}
return [...out];
});
const outboundTagOptions = computed(() => {
const out = new Set(['']);
for (const ob of props.templateSettings?.outbounds || []) {
if (ob.tag) out.add(ob.tag);
}
for (const t of props.clientReverseTags || []) {
if (t) out.add(t);
}
return [...out];
});
const balancerTagOptions = computed(() => {
const out = [''];
for (const b of props.templateSettings?.routing?.balancers || []) {
if (b.tag) out.push(b.tag);
}
return out;
});
function openAdd() {
editingRule.value = null;
editingIndex.value = null;
ruleModalOpen.value = true;
}
function openEdit(idx) {
editingRule.value = props.templateSettings.routing.rules[idx];
editingIndex.value = idx;
ruleModalOpen.value = true;
}
function onRuleConfirm(rule) {
// Empty submit (e.g. user clears every field) collapses to an
// object with only `type: "field"`. Match legacy: skip the write
// when the result is essentially empty.
if (JSON.stringify(rule).length <= 3) {
ruleModalOpen.value = false;
return;
}
if (editingIndex.value == null) {
props.templateSettings.routing.rules.push(rule);
} else {
props.templateSettings.routing.rules[editingIndex.value] = rule;
}
ruleModalOpen.value = false;
}
function confirmDelete(idx) {
Modal.confirm({
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => { props.templateSettings.routing.rules.splice(idx, 1); },
});
}
function moveUp(idx) {
if (idx <= 0) return;
const rules = props.templateSettings.routing.rules;
[rules[idx - 1], rules[idx]] = [rules[idx], rules[idx - 1]];
}
function moveDown(idx) {
const rules = props.templateSettings.routing.rules;
if (idx >= rules.length - 1) return;
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
}
const draggedIndex = ref(null);
const dropTargetIndex = ref(null);
let dragStartY = 0;
let dragMoved = false;
function onHandlePointerDown(idx, ev) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
draggedIndex.value = idx;
dropTargetIndex.value = idx;
dragStartY = ev.clientY;
dragMoved = false;
document.addEventListener('pointermove', onDragPointerMove);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointercancel', onDragPointerUp);
}
function onDragPointerMove(ev) {
if (draggedIndex.value == null) return;
if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
dragMoved = true;
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const idx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(idx)) dropTargetIndex.value = idx;
}
function onDragPointerUp() {
document.removeEventListener('pointermove', onDragPointerMove);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointercancel', onDragPointerUp);
const from = draggedIndex.value;
const to = dropTargetIndex.value;
draggedIndex.value = null;
dropTargetIndex.value = null;
if (!dragMoved || from == null || to == null || from === to) return;
const rules = props.templateSettings.routing.rules;
const [moved] = rules.splice(from, 1);
rules.splice(to, 0, moved);
}
function rowProps(_record, index) {
const classes = [];
if (draggedIndex.value === index) classes.push('row-dragging');
if (dropTargetIndex.value === index && draggedIndex.value !== index) {
classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
}
return { class: classes.join(' ') };
}
// === Columns =========================================================
// Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [
{ title: '#', align: 'center', width: 100, key: 'action' },
{ title: 'Source', align: 'left', width: 180, key: 'source' },
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' },
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
{ title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
]);
const columns = computed(() => desktopColumns.value);
function ruleCriteriaChips(rule) {
const chips = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
function chipPreview(value) {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-button type="primary" @click="openAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Routings') }}
</a-button>
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
phone (~520px of columns alone), so render each rule as a
compact card with the routing summary + criteria chips. -->
<div v-if="isMobile" class="rule-list">
<div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
'row-dragging': draggedIndex === index,
'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
}" :data-row-key="index">
<div class="rule-card-head">
<HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
<span class="rule-number">#{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="rule-flow">
<div class="flow-side">
<span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
<a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
{{ chipPreview(rule.inboundTag) }}
</a-tag>
<span v-else class="criterion-empty">any</span>
</div>
<span class="flow-arrow">→</span>
<div class="flow-side flow-side-target">
<span class="flow-label">{{
rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
}}</span>
<a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
<ExportOutlined /> {{ rule.outboundTag }}
</a-tag>
<a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
<ClusterOutlined /> {{ rule.balancerTag }}
</a-tag>
<span v-else class="criterion-empty">—</span>
</div>
</div>
<div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
<a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
<span class="criterion-chip">
<span class="criterion-chip-label">{{ chip.label }}</span>
<span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
</span>
</a-tooltip>
</div>
</div>
<div v-if="!rows.length" class="rule-empty">—</div>
</div>
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
:scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
<template #bodyCell="{ column, record, index }">
<!-- ============== # / actions ============== -->
<template v-if="column.key === 'action'">
<div class="action-cell">
<HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
@pointerdown="onHandlePointerDown(index, $event)" />
<span class="row-index">{{ index + 1 }}</span>
<div :class="!isMobile ? 'action-buttons' : ''">
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="isMobile" @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</template>
<!-- ============== Source ============== -->
<template v-else-if="column.key === 'source'">
<div class="criterion-flow">
<a-tooltip v-if="record.sourceIP" :title="`Source IP: ${record.sourceIP}`">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">{{ csv(record.sourcePort)[0] }}</span>
<span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length
- 1 }}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.vlessRoute" :title="`VLESS route: ${record.vlessRoute}`">
<span class="criterion-row">
<span class="criterion-label">VLESS</span>
<span class="criterion-value">{{ csv(record.vlessRoute)[0] }}</span>
<span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length
- 1 }}</span>
</span>
</a-tooltip>
<span v-if="!record.sourceIP && !record.sourcePort && !record.vlessRoute" class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Network ============== -->
<template v-else-if="column.key === 'network'">
<div class="criterion-flow">
<a-tooltip v-if="record.network" :title="`L4: ${record.network}`">
<span class="criterion-row">
<span class="criterion-label">L4</span>
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
<span class="criterion-row">
<span class="criterion-label">Protocol</span>
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
<span class="criterion-row">
<span class="criterion-label">Attrs</span>
<span class="criterion-value">{{ csv(record.attrs)[0] }}</span>
</span>
</a-tooltip>
<span v-if="!record.network && !record.protocol && !record.attrs" class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Destination ============== -->
<template v-else-if="column.key === 'destination'">
<div class="criterion-flow">
<a-tooltip v-if="record.ip" :title="`Destination IP: ${record.ip}`">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">{{ csv(record.ip)[0] }}</span>
<span v-if="csv(record.ip).length > 1" class="criterion-more">+{{ csv(record.ip).length - 1 }}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.domain" :title="`Domain: ${record.domain}`">
<span class="criterion-row">
<span class="criterion-label">Domain</span>
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
}}</span>
</span>
</a-tooltip>
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Inbound ============== -->
<template v-else-if="column.key === 'inbound'">
<div class="criterion-flow">
<a-tooltip v-if="record.inboundTag" :title="`Inbound tag: ${record.inboundTag}`">
<span class="criterion-row">
<span class="criterion-label">Tag</span>
<span class="criterion-value">{{ csv(record.inboundTag)[0] }}</span>
<span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length
- 1 }}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.user" :title="`User: ${record.user}`">
<span class="criterion-row">
<span class="criterion-label">User</span>
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
}}</span>
</span>
</a-tooltip>
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Outbound ============== -->
<template v-else-if="column.key === 'outbound'">
<div class="target-cell">
<div v-if="record.outboundTag" class="target-row">
<ExportOutlined class="target-icon" />
<a-tag color="green">{{ record.outboundTag }}</a-tag>
</div>
<span v-else class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Balancer ============== -->
<template v-else-if="column.key === 'balancer'">
<div class="target-cell">
<div v-if="record.balancerTag" class="target-row">
<ClusterOutlined class="target-icon" />
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
</div>
<span v-else class="criterion-empty">—</span>
</div>
</template>
</template>
</a-table>
<RuleFormModal v-model:open="ruleModalOpen" :rule="editingRule" :inbound-tags="inboundTagOptions"
:outbound-tags="outboundTagOptions" :balancer-tags="balancerTagOptions" @confirm="onRuleConfirm" />
</a-space>
</template>
<style scoped>
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.drag-handle {
cursor: grab;
opacity: 0.35;
font-size: 14px;
padding: 4px;
margin: -4px;
touch-action: none;
transition: opacity 0.15s;
}
.drag-handle:hover {
opacity: 0.8;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.row-dragging) {
opacity: 0.4;
}
:deep(.drop-before > td) {
box-shadow: inset 0 2px 0 0 #1677ff;
}
:deep(.drop-after > td) {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.row-index {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.criterion-flow {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
}
.criterion-row {
display: inline-flex;
align-items: baseline;
gap: 4px;
white-space: nowrap;
}
.criterion-label {
font-size: 10px;
text-transform: uppercase;
opacity: 0.55;
letter-spacing: 0.04em;
}
.criterion-value {
font-weight: 500;
}
.criterion-more {
font-size: 11px;
padding: 0 5px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.06);
}
:global(body.dark) .criterion-more {
background: rgba(255, 255, 255, 0.1);
}
.criterion-empty {
opacity: 0.4;
}
.target-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.target-row {
display: flex;
align-items: center;
gap: 4px;
}
.target-icon {
font-size: 12px;
opacity: 0.6;
}
.danger {
color: #ff4d4f;
}
/* === Mobile card list ====================================== */
.rule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-card, #fff);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 8px;
transition: opacity 0.15s, box-shadow 0.15s;
}
.rule-card.row-dragging {
opacity: 0.4;
}
.rule-card.drop-before {
box-shadow: inset 0 2px 0 0 #1677ff;
}
.rule-card.drop-after {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.rule-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.rule-number {
font-weight: 600;
font-size: 13px;
opacity: 0.75;
flex: 1;
}
.rule-flow {
display: flex;
align-items: center;
gap: 8px;
}
.flow-side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.flow-side-target {
align-items: flex-end;
text-align: right;
}
.flow-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
}
.flow-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.flow-arrow {
font-size: 16px;
opacity: 0.45;
flex-shrink: 0;
}
.rule-criteria {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-top: 6px;
border-top: 1px dashed rgba(128, 128, 128, 0.2);
}
.criterion-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 1px 6px;
font-size: 11px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.criterion-chip-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
.criterion-chip-value {
font-weight: 500;
}
.rule-empty {
padding: 24px;
text-align: center;
opacity: 0.4;
}
:global(body.dark) .rule-card {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
:global(body.dark) .criterion-chip {
background: rgba(255, 255, 255, 0.06);
}
</style>
+310
View File
@@ -0,0 +1,310 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
export interface RoutingRule {
type?: string;
domain?: string | string[];
ip?: string | string[];
port?: string;
sourcePort?: string;
vlessRoute?: string;
network?: string;
sourceIP?: string | string[];
user?: string | string[];
inboundTag?: string[];
protocol?: string[];
attrs?: Record<string, string>;
outboundTag?: string;
balancerTag?: string;
[key: string]: unknown;
}
interface RuleFormModalProps {
open: boolean;
rule: RoutingRule | null;
inboundTags: string[];
outboundTags: string[];
balancerTags: string[];
onClose: () => void;
onConfirm: (rule: Record<string, unknown>) => void;
}
interface FormState {
domain: string;
ip: string;
port: string;
sourcePort: string;
vlessRoute: string;
network: string;
sourceIP: string;
user: string;
inboundTag: string[];
protocol: string[];
attrs: [string, string][];
outboundTag: string;
balancerTag: string;
}
const initialForm = (): FormState => ({
domain: '',
ip: '',
port: '',
sourcePort: '',
vlessRoute: '',
network: '',
sourceIP: '',
user: '',
inboundTag: [],
protocol: [],
attrs: [],
outboundTag: '',
balancerTag: '',
});
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
function csv(value: string): string[] {
if (!value) return [];
return value.split(',').map((s) => s.trim()).filter(Boolean);
}
export default function RuleFormModal({
open,
rule,
inboundTags,
outboundTags,
balancerTags,
onClose,
onConfirm,
}: RuleFormModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(initialForm);
const isEdit = rule != null;
useEffect(() => {
if (!open) return;
if (rule) {
setForm({
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
port: rule.port || '',
sourcePort: rule.sourcePort || '',
vlessRoute: rule.vlessRoute || '',
network: rule.network || '',
sourceIP: Array.isArray(rule.sourceIP) ? rule.sourceIP.join(',') : rule.sourceIP || '',
user: Array.isArray(rule.user) ? rule.user.join(',') : rule.user || '',
inboundTag: rule.inboundTag || [],
protocol: rule.protocol || [],
attrs: rule.attrs ? Object.entries(rule.attrs) : [],
outboundTag: rule.outboundTag || '',
balancerTag: rule.balancerTag || '',
});
} else {
setForm(initialForm());
}
}, [open, rule]);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
function submit() {
const built: Record<string, unknown> = {
type: 'field',
domain: csv(form.domain),
ip: csv(form.ip),
port: form.port,
sourcePort: form.sourcePort,
vlessRoute: form.vlessRoute,
network: form.network,
sourceIP: csv(form.sourceIP),
user: csv(form.user),
inboundTag: form.inboundTag,
protocol: form.protocol,
attrs: Object.fromEntries(form.attrs.filter(([k]) => k)),
outboundTag: form.outboundTag === '' ? undefined : form.outboundTag,
balancerTag: form.balancerTag === '' ? undefined : form.balancerTag,
};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(built)) {
if (v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
if (v === '') continue;
out[k] = v;
}
onConfirm(out);
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Routings')}`
: `+ ${t('pages.xray.Routings')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={640}
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item
label={
<Tooltip title="Comma-separated list">
Source IPs <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.sourceIP} onChange={(e) => update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</Form.Item>
<Form.Item
label={
<Tooltip title="Comma-separated list">
Source port <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item
label={
<Tooltip title="Comma-separated list">
VLESS route <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item label="Network">
<Select
value={form.network}
onChange={(v) => update('network', v)}
options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
/>
</Form.Item>
<Form.Item label="Protocol">
<Select
mode="multiple"
value={form.protocol}
onChange={(v) => update('protocol', v)}
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
/>
</Form.Item>
<Form.Item label="Attributes">
<Button size="small" icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{form.attrs.map((attr, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
placeholder="Name"
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
update('attrs', next);
}}
/>
<Input
value={attr[1]}
placeholder="Value"
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
update('attrs', next);
}}
/>
<Button
icon={<MinusOutlined />}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/>
</Space.Compact>
))}
</Form.Item>
<Form.Item
label={
<Tooltip title="Comma-separated list">
IP <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.ip} onChange={(e) => update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</Form.Item>
<Form.Item
label={
<Tooltip title="Comma-separated list">
Domain <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.domain} onChange={(e) => update('domain', e.target.value)} placeholder="google.com, geosite:cn" />
</Form.Item>
<Form.Item
label={
<Tooltip title="Comma-separated list">
User <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.user} onChange={(e) => update('user', e.target.value)} placeholder="email address" />
</Form.Item>
<Form.Item
label={
<Tooltip title="Comma-separated list">
Port <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item label="Inbound tags">
<Select
mode="multiple"
value={form.inboundTag}
onChange={(v) => update('inboundTag', v)}
options={inboundTags.map((tag) => ({ value: tag, label: tag }))}
/>
</Form.Item>
<Form.Item label="Outbound tag">
<Select
value={form.outboundTag}
onChange={(v) => update('outboundTag', v)}
options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
<Form.Item
label={
<Tooltip title="Routes traffic through one of the configured load balancers">
Balancer tag <QuestionCircleOutlined />
</Tooltip>
}
>
<Select
value={form.balancerTag}
onChange={(v) => update('balancerTag', v)}
options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
</Form>
</Modal>
);
}
-275
View File
@@ -1,275 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue';
// Routing-rule editor — mirrors xray_rule_modal.html. We keep the
// CSV-style fields (domain / ip / sourceIP / user / port / sourcePort /
// vlessRoute) as plain strings while the modal is open and split them
// back to arrays on submit, just like the legacy ruleModal.getResult.
const props = defineProps({
open: { type: Boolean, default: false },
// null when adding, the rule object when editing.
rule: { type: Object, default: null },
// Tag pools sourced from templateSettings.{inbounds,outbounds,routing.balancers}
// and the parent's inboundTags / clientReverseTags / dnsTag.
inboundTags: { type: Array, default: () => [] },
outboundTags: { type: Array, default: () => [] },
balancerTags: { type: Array, default: () => [''] },
});
const emit = defineEmits(['update:open', 'confirm']);
const form = reactive({
domain: '',
ip: '',
port: '',
sourcePort: '',
vlessRoute: '',
network: '',
sourceIP: '',
user: '',
inboundTag: [],
protocol: [],
attrs: [], // [[key, value], ...]
outboundTag: '',
balancerTag: '',
});
const isEdit = ref(false);
function reset() {
form.domain = '';
form.ip = '';
form.port = '';
form.sourcePort = '';
form.vlessRoute = '';
form.network = '';
form.sourceIP = '';
form.user = '';
form.inboundTag = [];
form.protocol = [];
form.attrs = [];
form.outboundTag = '';
form.balancerTag = '';
}
watch(() => props.open, (next) => {
if (!next) return;
if (props.rule) {
isEdit.value = true;
const r = props.rule;
form.domain = Array.isArray(r.domain) ? r.domain.join(',') : (r.domain || '');
form.ip = Array.isArray(r.ip) ? r.ip.join(',') : (r.ip || '');
form.port = r.port || '';
form.sourcePort = r.sourcePort || '';
form.vlessRoute = r.vlessRoute || '';
form.network = r.network || '';
form.sourceIP = Array.isArray(r.sourceIP) ? r.sourceIP.join(',') : (r.sourceIP || '');
form.user = Array.isArray(r.user) ? r.user.join(',') : (r.user || '');
form.inboundTag = r.inboundTag || [];
form.protocol = r.protocol || [];
// Attrs in the wire shape are an object — flatten to [[k,v]] pairs.
form.attrs = r.attrs ? Object.entries(r.attrs) : [];
form.outboundTag = r.outboundTag || '';
form.balancerTag = r.balancerTag || '';
} else {
isEdit.value = false;
reset();
}
});
function close() { emit('update:open', false); }
function csv(value) {
if (!value) return [];
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
}
function buildResult() {
const rule = {
type: 'field',
domain: csv(form.domain),
ip: csv(form.ip),
port: form.port,
sourcePort: form.sourcePort,
vlessRoute: form.vlessRoute,
network: form.network,
sourceIP: csv(form.sourceIP),
user: csv(form.user),
inboundTag: form.inboundTag,
protocol: form.protocol,
attrs: Object.fromEntries(form.attrs.filter(([k]) => k)),
outboundTag: form.outboundTag === '' ? undefined : form.outboundTag,
balancerTag: form.balancerTag === '' ? undefined : form.balancerTag,
};
// Strip empty arrays / objects / strings so the final wire JSON
// matches what the legacy `getResult` produces.
const out = {};
for (const [k, v] of Object.entries(rule)) {
if (v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
if (v === '') continue;
out[k] = v;
}
return out;
}
function onOk() {
emit('confirm', buildResult());
}
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Routings')}`
: `+ ${t('pages.xray.Routings')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
);
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="640px"
@ok="onOk" @cancel="close">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">
Source IPs
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.sourceIP" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">
Source port
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.sourcePort" placeholder="53,443,1000-2000" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">
VLESS route
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.vlessRoute" placeholder="53,443,1000-2000" />
</a-form-item>
<a-form-item label="Network">
<a-select v-model:value="form.network">
<a-select-option v-for="n in NETWORKS" :key="n" :value="n">{{ n || '(any)' }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Protocol">
<a-select v-model:value="form.protocol" mode="multiple">
<a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Attributes">
<a-button size="small" @click="form.attrs.push(['', ''])">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(attr, idx) in form.attrs" :key="idx" compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="attr[0]" placeholder="Name">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="attr[1]" placeholder="Value" />
<a-button @click="form.attrs.splice(idx, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">IP
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">Domain
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.domain" placeholder="google.com, geosite:cn" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">User
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.user" placeholder="email address" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Comma-separated list">Port
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-input v-model:value="form.port" placeholder="53,443,1000-2000" />
</a-form-item>
<a-form-item label="Inbound tags">
<a-select v-model:value="form.inboundTag" mode="multiple">
<a-select-option v-for="tag in inboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Outbound tag">
<a-select v-model:value="form.outboundTag">
<a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
}}</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Routes traffic through one of the configured load balancers">
Balancer tag
<QuestionCircleOutlined />
</a-tooltip>
</template>
<a-select v-model:value="form.balancerTag">
<a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
}}</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.mb-8 {
margin-bottom: 8px;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
.warp-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.warp-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.warp-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.row-odd {
background: rgba(0, 0, 0, 0.03);
}
body.dark .row-odd {
background: rgba(255, 255, 255, 0.04);
}
.zero-margin {
margin: 0;
}
.my-8 {
margin: 8px 0;
}
.mt-8 {
margin-top: 8px;
}
.my-10 {
margin: 10px 0;
}
.ml-8 {
margin-left: 8px;
}
.license-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.license-error {
flex: 1;
min-width: 0;
}
+357
View File
@@ -0,0 +1,357 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Collapse,
Divider,
Form,
Input,
message,
Modal,
Tag,
} from 'antd';
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
import './WarpModal.css';
interface WarpModalProps {
open: boolean;
templateSettings: { outbounds?: { tag?: string }[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: { index: number; outbound: Record<string, unknown> }) => void;
onRemoveOutbound: (tag: string) => void;
}
interface WarpData {
access_token?: string;
device_id?: string;
license_key?: string;
private_key?: string;
client_id?: string;
}
interface WarpConfig {
name?: string;
model?: string;
enabled?: boolean;
config?: {
interface?: { addresses?: { v4?: string; v6?: string } };
peers?: { public_key?: string; endpoint?: { host?: string } }[];
};
account?: {
account_type?: string;
role?: string;
premium_data?: number;
quota?: number;
usage?: number;
};
}
function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
const out: string[] = [];
if (addrs.v4) out.push(`${addrs.v4}/32`);
if (addrs.v6) out.push(`${addrs.v6}/128`);
return out;
}
function reservedFor(clientId?: string): number[] {
if (!clientId) return [];
const decoded = atob(clientId);
const out: number[] = [];
for (let i = 0; i < decoded.length; i += 1) out.push(decoded.charCodeAt(i));
return out;
}
export default function WarpModal({
open,
templateSettings,
onClose,
onAddOutbound,
onResetOutbound,
onRemoveOutbound,
}: WarpModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [warpPlus, setWarpPlus] = useState('');
const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
const warpOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag === 'warp');
}, [templateSettings?.outbounds]);
const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => {
const cfg = config?.config;
if (!cfg?.peers?.length) return;
const peer = cfg.peers[0];
setStagedOutbound({
tag: 'warp',
protocol: 'wireguard',
settings: {
mtu: 1420,
secretKey: data?.private_key,
address: addressesFor(cfg.interface?.addresses || {}),
reserved: reservedFor(data?.client_id),
domainStrategy: 'ForceIP',
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
noKernelTun: false,
},
});
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/warp/data');
if (msg?.success) {
const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
setWarpConfig(null);
setStagedOutbound(null);
setLicenseError('');
fetchData();
}, [open, fetchData]);
async function register() {
setLoading(true);
try {
const keys = Wireguard.generateKeypair();
const msg = await HttpUtil.post('/panel/xray/warp/reg', keys);
if (msg?.success) {
const resp = JSON.parse(msg.obj);
setWarpData(resp.data);
setWarpConfig(resp.config);
collectConfig(resp.data, resp.config);
}
} finally {
setLoading(false);
}
}
async function getConfig() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/warp/config');
if (msg?.success) {
const parsed = JSON.parse(msg.obj);
setWarpConfig(parsed);
collectConfig(warpData, parsed);
}
} finally {
setLoading(false);
}
}
async function updateLicense() {
if (warpPlus.length < 26) return;
setLoading(true);
setLicenseError('');
try {
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus });
if (msg?.success) {
setWarpData(JSON.parse(msg.obj));
setWarpConfig(null);
setWarpPlus('');
} else {
setLicenseError(msg?.msg || 'Failed to set WARP license.');
}
} finally {
setLoading(false);
}
}
async function delConfig() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/warp/del');
if (msg?.success) {
setWarpData(null);
setWarpConfig(null);
setStagedOutbound(null);
onRemoveOutbound('warp');
onClose();
}
} finally {
setLoading(false);
}
}
function addOutbound() {
if (!stagedOutbound) {
messageApi.warning('Fetch the WARP config first.');
return;
}
onAddOutbound(stagedOutbound);
onClose();
}
function resetOutbound() {
if (!stagedOutbound) return;
onResetOutbound({ index: warpOutboundIndex, outbound: stagedOutbound });
onClose();
}
const hasWarp = !ObjectUtil.isEmpty(warpData);
const hasConfig = !ObjectUtil.isEmpty(warpConfig);
return (
<>
{messageContextHolder}
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
{!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
Create WARP account
</Button>
) : (
<>
<table className="warp-data-table">
<tbody>
<tr className="row-odd">
<td>Access token</td>
<td>{warpData?.access_token}</td>
</tr>
<tr>
<td>Device ID</td>
<td>{warpData?.device_id}</td>
</tr>
<tr className="row-odd">
<td>License key</td>
<td>{warpData?.license_key}</td>
</tr>
<tr>
<td>Private key</td>
<td>{warpData?.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" icon={<DeleteOutlined />} onClick={delConfig}>
Delete account
</Button>
<Divider className="zero-margin">Settings</Divider>
<Collapse
className="my-10"
items={[
{
key: '1',
label: 'WARP / WARP+ license key',
children: (
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label="Key">
<Input
value={warpPlus}
placeholder="26-char WARP+ key"
onChange={(e) => {
setWarpPlus(e.target.value);
setLicenseError('');
}}
/>
<div className="license-actions mt-8">
<Button
type="primary"
disabled={warpPlus.length < 26}
loading={loading}
onClick={updateLicense}
>
Update
</Button>
{licenseError && (
<Alert title={licenseError} type="error" showIcon className="license-error" />
)}
</div>
</Form.Item>
</Form>
),
},
]}
/>
<Divider className="zero-margin">Account info</Divider>
<Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
Refresh
</Button>
{hasConfig && (
<>
<table className="warp-data-table">
<tbody>
<tr className="row-odd">
<td>Device name</td>
<td>{warpConfig?.name}</td>
</tr>
<tr>
<td>Device model</td>
<td>{warpConfig?.model}</td>
</tr>
<tr className="row-odd">
<td>Device enabled</td>
<td>{String(warpConfig?.enabled)}</td>
</tr>
{warpConfig?.account && (
<>
<tr>
<td>Account type</td>
<td>{warpConfig.account.account_type}</td>
</tr>
<tr className="row-odd">
<td>Role</td>
<td>{warpConfig.account.role}</td>
</tr>
<tr>
<td>WARP+ data</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
</tr>
<tr className="row-odd">
<td>Quota</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
</tr>
{warpConfig.account.usage != null && (
<tr>
<td>Usage</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
</tr>
)}
</>
)}
</tbody>
</table>
<Divider className="my-10">Outbound status</Divider>
{warpOutboundIndex >= 0 ? (
<>
<Tag color="green">Enabled</Tag>
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
Reset
</Button>
</>
) : (
<>
<Tag color="orange">Disabled</Tag>
<Button type="primary" loading={loading} className="ml-8" icon={<PlusOutlined />} onClick={addOutbound}>
Add outbound
</Button>
</>
)}
</>
)}
</>
)}
</Modal>
</>
);
}
-381
View File
@@ -1,381 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
// Cloudflare WARP provisioning modal. Mirrors the legacy warp_modal:
// • when no WARP account is registered yet, a single Create button
// generates a wireguard keypair locally and posts it to
// /panel/xray/warp/reg to create a Cloudflare device record;
// • once registered, the modal displays the access_token /
// device_id / license_key / private_key, lets the user upgrade
// to WARP+ via /panel/xray/warp/license, fetches the current
// account config (premium data / quota / usage) via
// /panel/xray/warp/config, and stages a wireguard outbound
// ready for adding to templateSettings.outbounds.
const props = defineProps({
open: { type: Boolean, default: false },
templateSettings: { type: Object, default: null },
});
const emit = defineEmits(['update:open', 'add-outbound', 'reset-outbound', 'remove-outbound']);
const loading = ref(false);
const warpData = ref(null);
const warpConfig = ref(null);
const warpPlus = ref('');
const licenseError = ref('');
// Held in memory so the parent's add/reset handlers receive the same
// object the modal computed from getConfig().
const stagedOutbound = ref(null);
const warpOutboundIndex = computed(() => {
const list = props.templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag === 'warp');
});
watch(() => props.open, (next) => {
if (!next) return;
warpConfig.value = null;
stagedOutbound.value = null;
licenseError.value = '';
fetchData();
});
async function fetchData() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/warp/data');
if (msg?.success) {
const raw = msg.obj;
warpData.value = raw && raw.length > 0 ? JSON.parse(raw) : null;
}
} finally {
loading.value = false;
}
}
async function register() {
loading.value = true;
try {
const keys = Wireguard.generateKeypair();
const msg = await HttpUtil.post('/panel/xray/warp/reg', keys);
if (msg?.success) {
const resp = JSON.parse(msg.obj);
warpData.value = resp.data;
warpConfig.value = resp.config;
collectConfig();
}
} finally {
loading.value = false;
}
}
async function getConfig() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/warp/config');
if (msg?.success) {
warpConfig.value = JSON.parse(msg.obj);
collectConfig();
}
} finally {
loading.value = false;
}
}
async function updateLicense() {
if (warpPlus.value.length < 26) return;
loading.value = true;
licenseError.value = '';
try {
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
if (msg?.success) {
warpData.value = JSON.parse(msg.obj);
warpConfig.value = null;
warpPlus.value = '';
} else {
licenseError.value = msg?.msg || 'Failed to set WARP license.';
}
} finally {
loading.value = false;
}
}
async function delConfig() {
loading.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/warp/del');
if (msg?.success) {
warpData.value = null;
warpConfig.value = null;
stagedOutbound.value = null;
emit('remove-outbound', 'warp');
close();
}
} finally {
loading.value = false;
}
}
// Build the wireguard outbound shape from the WARP account data.
// Keep this here (not on the parent) because the encoding of the
// reserved bytes from `client_id` is WARP-specific.
function collectConfig() {
const config = warpConfig.value?.config;
if (!config?.peers?.length) return;
const peer = config.peers[0];
stagedOutbound.value = {
tag: 'warp',
protocol: 'wireguard',
settings: {
mtu: 1420,
secretKey: warpData.value.private_key,
address: addressesFor(config.interface?.addresses || {}),
reserved: reservedFor(warpData.value.client_id),
domainStrategy: 'ForceIP',
peers: [{
publicKey: peer.public_key,
endpoint: peer.endpoint?.host,
}],
noKernelTun: false,
},
};
}
function addressesFor(addrs) {
const out = [];
if (addrs.v4) out.push(`${addrs.v4}/32`);
if (addrs.v6) out.push(`${addrs.v6}/128`);
return out;
}
// WARP encodes its reserved bytes as a base64-decoded triplet pulled
// from `client_id`. We turn those bytes into an int array — same
// algorithm the legacy modal used.
function reservedFor(clientId) {
if (!clientId) return [];
const decoded = atob(clientId);
const out = [];
for (let i = 0; i < decoded.length; i++) out.push(decoded.charCodeAt(i));
return out;
}
function addOutbound() {
if (!stagedOutbound.value) {
message.warning('Fetch the WARP config first.');
return;
}
emit('add-outbound', stagedOutbound.value);
close();
}
function resetOutbound() {
if (!stagedOutbound.value) return;
emit('reset-outbound', { index: warpOutboundIndex.value, outbound: stagedOutbound.value });
close();
}
function close() { emit('update:open', false); }
const hasWarp = computed(() => !ObjectUtil.isEmpty(warpData.value));
const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
</script>
<template>
<a-modal :open="open" title="Cloudflare WARP" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
<!-- WARP / NordVPN provisioning forms keep technical wire labels in
English on purpose: they map directly to API field names users
look up in vendor docs. Only the primary action buttons +
dialog headers translate. -->
<!-- Not registered yet → single Create CTA -->
<template v-if="!hasWarp">
<a-button type="primary" :loading="loading" @click="register">
<template #icon>
<ApiOutlined />
</template>
Create WARP account
</a-button>
</template>
<!-- Registered → account display + license + config + outbound controls -->
<template v-else>
<table class="warp-data-table">
<tbody>
<tr class="row-odd">
<td>Access token</td>
<td>{{ warpData.access_token }}</td>
</tr>
<tr>
<td>Device ID</td>
<td>{{ warpData.device_id }}</td>
</tr>
<tr class="row-odd">
<td>License key</td>
<td>{{ warpData.license_key }}</td>
</tr>
<tr>
<td>Private key</td>
<td>{{ warpData.private_key }}</td>
</tr>
</tbody>
</table>
<a-button :loading="loading" type="primary" danger class="mt-8" @click="delConfig">
<template #icon>
<DeleteOutlined />
</template>
Delete account
</a-button>
<a-divider class="zero-margin">Settings</a-divider>
<a-collapse class="my-10">
<a-collapse-panel header="WARP / WARP+ license key">
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item label="Key">
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" @update:value="licenseError = ''" />
<div class="license-actions mt-8">
<a-button type="primary" :disabled="warpPlus.length < 26" :loading="loading"
@click="updateLicense">Update</a-button>
<a-alert v-if="licenseError" :message="licenseError" type="error" show-icon class="license-error" />
</div>
</a-form-item>
</a-form>
</a-collapse-panel>
</a-collapse>
<a-divider class="zero-margin">Account info</a-divider>
<a-button class="my-8" :loading="loading" type="primary" @click="getConfig">
<template #icon>
<SyncOutlined />
</template>
Refresh
</a-button>
<template v-if="hasConfig">
<table class="warp-data-table">
<tbody>
<tr class="row-odd">
<td>Device name</td>
<td>{{ warpConfig.name }}</td>
</tr>
<tr>
<td>Device model</td>
<td>{{ warpConfig.model }}</td>
</tr>
<tr class="row-odd">
<td>Device enabled</td>
<td>{{ warpConfig.enabled }}</td>
</tr>
<template v-if="warpConfig.account">
<tr>
<td>Account type</td>
<td>{{ warpConfig.account.account_type }}</td>
</tr>
<tr class="row-odd">
<td>Role</td>
<td>{{ warpConfig.account.role }}</td>
</tr>
<tr>
<td>WARP+ data</td>
<td>{{ SizeFormatter.sizeFormat(warpConfig.account.premium_data) }}</td>
</tr>
<tr class="row-odd">
<td>Quota</td>
<td>{{ SizeFormatter.sizeFormat(warpConfig.account.quota) }}</td>
</tr>
<tr v-if="warpConfig.account.usage">
<td>Usage</td>
<td>{{ SizeFormatter.sizeFormat(warpConfig.account.usage) }}</td>
</tr>
</template>
</tbody>
</table>
<a-divider class="my-10">Outbound status</a-divider>
<template v-if="warpOutboundIndex >= 0">
<a-tag color="green">Enabled</a-tag>
<a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
Reset
</a-button>
</template>
<template v-else>
<a-tag color="orange">Disabled</a-tag>
<a-button type="primary" :loading="loading" class="ml-8" @click="addOutbound">
<template #icon>
<PlusOutlined />
</template>
Add outbound
</a-button>
</template>
</template>
</template>
</a-modal>
</template>
<style scoped>
.warp-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.warp-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.warp-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.row-odd {
background: rgba(0, 0, 0, 0.03);
}
:global(body.dark) .row-odd {
background: rgba(255, 255, 255, 0.04);
}
.zero-margin {
margin: 0;
}
.my-8 {
margin: 8px 0;
}
.mt-8 {
margin-top: 8px;
}
.my-10 {
margin: 10px 0;
}
.ml-8 {
margin-left: 8px;
}
.license-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.license-error {
flex: 1;
min-width: 0;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
.xray-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.xray-page.is-dark {
--bg-page: #1a1b1f;
--bg-card: #23252b;
}
.xray-page.is-dark.is-ultra {
--bg-page: #000;
--bg-card: #101013;
}
.xray-page .ant-layout,
.xray-page .ant-layout-content {
background: transparent;
}
.xray-page .content-shell {
background: transparent;
}
.xray-page .content-area {
padding: 24px;
}
.xray-page .loading-spacer {
min-height: calc(100vh - 120px);
}
.xray-page .header-row {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.xray-page .header-actions {
padding: 4px;
}
.xray-page .header-info {
display: flex;
justify-content: flex-end;
}
.xray-page .restart-icon {
font-size: 16px;
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
}
.xray-page .restart-result {
max-width: 480px;
white-space: pre-wrap;
font-size: 12px;
margin: 0;
}
.xray-page .advanced-meta h4 {
margin: 0 0 4px;
}
.xray-page .advanced-meta p {
margin: 0;
opacity: 0.7;
}
.xray-page .icons-only .ant-tabs-nav {
margin-bottom: 8px;
}
.xray-page .icons-only .ant-tabs-nav-wrap {
width: 100%;
}
.xray-page .icons-only .ant-tabs-nav-list {
display: flex;
width: 100%;
}
.xray-page .icons-only .ant-tabs-tab {
flex: 1 1 0;
justify-content: center;
margin: 0;
padding: 10px 0;
}
.xray-page .icons-only .ant-tabs-tab .anticon {
margin: 0;
font-size: 18px;
}
.xray-page .icons-only .ant-tabs-nav-operations {
display: none;
}
+477
View File
@@ -0,0 +1,477 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Card,
Col,
ConfigProvider,
FloatButton,
Layout,
message,
Modal,
Popover,
Radio,
Result,
Row,
Space,
Spin,
Tabs,
Tooltip,
} from 'antd';
import {
SettingOutlined,
SwapOutlined,
UploadOutlined,
ClusterOutlined,
DatabaseOutlined,
CodeOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/components/AppSidebar';
import JsonEditor from '@/components/JsonEditor';
import { setMessageInstance } from '@/utils/messageBus';
import BasicsTab from './BasicsTab';
import RoutingTab from './RoutingTab';
import OutboundsTab from './OutboundsTab';
import BalancersTab from './BalancersTab';
import DnsTab from './DnsTab';
import WarpModal from './WarpModal';
import NordModal from './NordModal';
import '@/styles/page-cards.css';
import './XrayPage.css';
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
const SLUG_BY_KEY: Record<string, string> = {
'tpl-basic': 'basic',
'tpl-routing': 'routing',
'tpl-outbound': 'outbound',
'tpl-balancer': 'balancer',
'tpl-dns': 'dns',
'tpl-advanced': 'advanced',
};
const KEY_BY_SLUG: Record<string, string> = Object.fromEntries(
Object.entries(SLUG_BY_KEY).map(([k, v]) => [v, k]),
);
type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
export default function XrayPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const xs = useXraySetting();
const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
} = xs;
useWebSocket({ outbounds: applyOutboundsEvent as never });
const [modal, modalContextHolder] = Modal.useModal();
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
const [activeTabKey, setActiveTabKey] = useState(() => {
const slug = window.location.hash.slice(1);
return KEY_BY_SLUG[slug] || TAB_KEYS[0];
});
useEffect(() => {
function syncTabFromHash() {
const key = KEY_BY_SLUG[window.location.hash.slice(1)];
if (key) setActiveTabKey(key);
}
window.addEventListener('hashchange', syncTabFromHash);
return () => window.removeEventListener('hashchange', syncTabFromHash);
}, []);
function onTabChange(key: string) {
setActiveTabKey(key);
const slug = SLUG_BY_KEY[key];
if (slug && window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp');
const nordExist = !!templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-'));
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
async function onTestOutbound(idx: number, mode: string) {
const outbound = templateSettings?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode);
}
function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
tt.outbounds.push(outbound as never);
});
}
function onResetOutbound(payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag?: string }) {
mutate((tt) => {
if (!tt.outbounds || payload.index < 0) return;
tt.outbounds[payload.index] = payload.outbound as never;
if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) {
const rules = tt.routing?.rules || [];
for (const r of rules) {
if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag;
}
}
});
}
function onRemoveOutboundByTag(tag: string) {
mutate((tt) => {
if (!tt.outbounds) return;
const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
if (idx >= 0) tt.outbounds.splice(idx, 1);
});
}
function onRemoveOutboundByIndex(index: number) {
mutate((tt) => {
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
});
}
function onRemoveRoutingRules(payload: { prefix: string }) {
mutate((tt) => {
const rules = tt.routing?.rules;
if (!Array.isArray(rules)) return;
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
});
}
const advancedText = useMemo(() => {
if (advSettings === 'xraySetting') return xraySetting;
const tpl = templateSettings;
if (!tpl) return '';
try {
switch (advSettings) {
case 'inboundSettings': return JSON.stringify(tpl.inbounds || [], null, 2);
case 'outboundSettings': return JSON.stringify(tpl.outbounds || [], null, 2);
case 'routingRuleSettings': return JSON.stringify(tpl.routing?.rules || [], null, 2);
default: return '';
}
} catch {
return '';
}
}, [advSettings, xraySetting, templateSettings]);
function onAdvancedTextChange(next: string) {
if (advSettings === 'xraySetting') {
setXraySetting(next);
return;
}
let parsed;
try {
parsed = JSON.parse(next);
} catch {
return;
}
mutate((tt) => {
switch (advSettings) {
case 'inboundSettings':
tt.inbounds = parsed;
break;
case 'outboundSettings':
tt.outbounds = parsed;
break;
case 'routingRuleSettings':
if (!tt.routing) tt.routing = {};
tt.routing.rules = parsed;
break;
}
});
}
function confirmRestart() {
modal.confirm({
title: 'Restart xray?',
content: 'Reloads the xray service with the saved configuration.',
okText: 'Restart',
cancelText: 'Cancel',
onOk: () => restartXray(),
});
}
function onSaveAll() {
try {
JSON.parse(xraySetting);
} catch (e) {
messageApi.error(`Advanced JSON: ${(e as Error).message}`);
setActiveTabKey('tpl-advanced');
return;
}
saveAll();
}
const scrollTarget = () => document.getElementById('content-layout') || window;
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={spinning || !fetched} delay={200} description="Loading…" size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" onClick={fetchAll}>{t('check')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
<Col span={24}>
<Card hoverable>
<Row className="header-row">
<Col xs={24} sm={14} className="header-actions">
<Space>
<Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
{t('pages.xray.save')}
</Button>
<Button type="primary" danger disabled={!saveDisabled} onClick={confirmRestart}>
{t('pages.xray.restart')}
</Button>
{restartResult && (
<Popover
placement="rightTop"
title="Xray restart output"
content={<pre className="restart-result">{restartResult}</pre>}
>
<QuestionCircleOutlined className="restart-icon" />
</Popover>
)}
</Space>
</Col>
<Col xs={24} sm={10} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card hoverable>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
className={isMobile ? 'icons-only' : ''}
items={[
{
key: 'tpl-basic',
label: (
<Tooltip title={isMobile ? t('pages.xray.basicTemplate') : ''}>
<SettingOutlined />
{!isMobile && <span>{` ${t('pages.xray.basicTemplate')}`}</span>}
</Tooltip>
),
children: (
<BasicsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundTestUrl={outboundTestUrl}
onChangeOutboundTestUrl={setOutboundTestUrl}
warpExist={warpExist}
nordExist={nordExist}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onResetDefault={resetToDefault}
/>
),
},
{
key: 'tpl-routing',
label: (
<Tooltip title={isMobile ? t('pages.xray.Routings') : ''}>
<SwapOutlined />
{!isMobile && <span>{` ${t('pages.xray.Routings')}`}</span>}
</Tooltip>
),
children: (
<RoutingTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
isMobile={isMobile}
/>
),
},
{
key: 'tpl-outbound',
label: (
<Tooltip title={isMobile ? t('pages.xray.Outbounds') : ''}>
<UploadOutlined />
{!isMobile && <span>{` ${t('pages.xray.Outbounds')}`}</span>}
</Tooltip>
),
children: (
<OutboundsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
testingAll={testingAll}
inboundTags={inboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
/>
),
},
{
key: 'tpl-balancer',
label: (
<Tooltip title={isMobile ? t('pages.xray.Balancers') : ''}>
<ClusterOutlined />
{!isMobile && <span>{` ${t('pages.xray.Balancers')}`}</span>}
</Tooltip>
),
children: (
<BalancersTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
isMobile={isMobile}
/>
),
},
{
key: 'tpl-dns',
label: (
<Tooltip title={isMobile ? 'DNS' : ''}>
<DatabaseOutlined />
{!isMobile && <span> DNS</span>}
</Tooltip>
),
children: (
<DnsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
/>
),
},
{
key: 'tpl-advanced',
label: (
<Tooltip title={isMobile ? t('pages.xray.advancedTemplate') : ''}>
<CodeOutlined />
{!isMobile && <span>{` ${t('pages.xray.advancedTemplate')}`}</span>}
</Tooltip>
),
children: (
<>
<div className="advanced-meta">
<h4>{t('pages.xray.Template')}</h4>
<p>{t('pages.xray.TemplateDesc')}</p>
</div>
<Radio.Group
value={advSettings}
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
style={{ margin: '12px 0' }}
onChange={(e) => setAdvSettings(e.target.value)}
>
<Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
<Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
<Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
<Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
</Radio.Group>
<JsonEditor
value={advancedText}
onChange={onAdvancedTextChange}
minHeight="420px"
maxHeight="720px"
/>
</>
),
},
]}
/>
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<WarpModal
open={warpOpen}
templateSettings={templateSettings}
onClose={() => setWarpOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByTag}
/>
<NordModal
open={nordOpen}
templateSettings={templateSettings}
onClose={() => setNordOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByIndex}
onRemoveRoutingRules={onRemoveRoutingRules}
/>
</Layout>
</ConfigProvider>
);
}
-496
View File
@@ -1,496 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
SettingOutlined,
SwapOutlined,
UploadOutlined,
ClusterOutlined,
DatabaseOutlined,
CodeOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import BasicsTab from './BasicsTab.vue';
import RoutingTab from './RoutingTab.vue';
import OutboundsTab from './OutboundsTab.vue';
import BalancersTab from './BalancersTab.vue';
import DnsTab from './DnsTab.vue';
import WarpModal from './WarpModal.vue';
import NordModal from './NordModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import { useXraySetting } from './useXraySetting.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
const { t } = useI18n();
const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
applyOutboundsEvent,
} = useXraySetting();
useWebSocket({ outbounds: applyOutboundsEvent });
async function onTestOutbound(idx, mode = 'tcp') {
const outbound = templateSettings.value?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestAllOutbounds(mode = 'tcp') {
await testAllOutbounds(mode);
}
function onDeleteOutbound(idx) {
templateSettings.value.outbounds.splice(idx, 1);
outboundTestStates.value = Object.fromEntries(
Object.entries(outboundTestStates.value)
.filter(([k]) => Number(k) !== idx)
.map(([k, v]) => [Number(k) > idx ? Number(k) - 1 : Number(k), v]),
);
}
// === Advanced tab — radio-driven view ==============================
// Mirrors the legacy advanced page: a 4-way radio toggles which slice
// of the xray config the textarea edits — the full config, just the
// inbounds, just the outbounds, or just the routing rules. Each slice
// reads/writes through templateSettings so edits propagate to the
// dirty-poll and structured tabs.
const advSettings = ref('xraySetting');
const advancedText = computed({
get: () => {
if (advSettings.value === 'xraySetting') return xraySetting.value;
const t = templateSettings.value;
if (!t) return '';
try {
switch (advSettings.value) {
case 'inboundSettings':
return JSON.stringify(t.inbounds || [], null, 2);
case 'outboundSettings':
return JSON.stringify(t.outbounds || [], null, 2);
case 'routingRuleSettings':
return JSON.stringify(t.routing?.rules || [], null, 2);
default:
return '';
}
} catch (_e) {
return '';
}
},
set: (next) => {
if (advSettings.value === 'xraySetting') {
xraySetting.value = next;
return;
}
// Slice edits: parse-then-merge into templateSettings so the
// structured tabs and the dirty-poll re-stringify it cleanly.
let parsed;
try { parsed = JSON.parse(next); } catch (_e) { return; }
const t = templateSettings.value;
if (!t) return;
switch (advSettings.value) {
case 'inboundSettings':
t.inbounds = parsed;
break;
case 'outboundSettings':
t.outbounds = parsed;
break;
case 'routingRuleSettings':
if (!t.routing) t.routing = {};
t.routing.rules = parsed;
break;
}
},
});
// `WarpExist` / `NordExist` derive from the parsed templateSettings —
// the Basics tab gates its WARP / NordVPN domain selectors on whether
// the matching outbound is provisioned, falling back to a "configure"
// button that today just toasts (the modals land in 6-v).
const warpExist = computed(
() => !!templateSettings.value?.outbounds?.find((o) => o?.tag === 'warp'),
);
const nordExist = computed(
() => !!templateSettings.value?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')),
);
// === WARP / NordVPN provisioning modals ============================
const warpOpen = ref(false);
const nordOpen = ref(false);
function showWarp() { warpOpen.value = true; }
function showNord() { nordOpen.value = true; }
function ensureOutbounds() {
if (!templateSettings.value) return null;
if (!Array.isArray(templateSettings.value.outbounds)) {
templateSettings.value.outbounds = [];
}
return templateSettings.value.outbounds;
}
function onAddOutbound(outbound) {
const list = ensureOutbounds();
if (list) list.push(outbound);
}
function onResetOutbound({ index, outbound, oldTag, newTag }) {
const list = ensureOutbounds();
if (!list || index < 0) return;
list[index] = outbound;
// Tag rename across routing rules — preserves Nord's
// server-switch flow without dangling references.
if (oldTag && newTag && oldTag !== newTag) {
const rules = templateSettings.value?.routing?.rules || [];
for (const r of rules) {
if (r?.outboundTag === oldTag) r.outboundTag = newTag;
}
}
}
function onRemoveOutboundByTag(tag) {
const list = ensureOutbounds();
if (!list) return;
const idx = list.findIndex((o) => o?.tag === tag);
if (idx >= 0) list.splice(idx, 1);
}
function onRemoveOutboundByIndex(index) {
const list = ensureOutbounds();
if (list && index >= 0) list.splice(index, 1);
}
function onRemoveRoutingRules({ prefix }) {
const rules = templateSettings.value?.routing?.rules;
if (!Array.isArray(rules)) return;
templateSettings.value.routing.rules = rules.filter(
(r) => !r?.outboundTag?.startsWith?.(prefix),
);
}
const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// See SettingsPage scrollTarget — wrap so `document` is in scope.
function scrollTarget() {
return document.getElementById('content-layout');
}
function confirmRestart() {
Modal.confirm({
title: 'Restart xray?',
content: 'Reloads the xray service with the saved configuration.',
okText: 'Restart',
cancelText: 'Cancel',
onOk: () => restartXray(),
});
}
const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
const slugByKey = {
'tpl-basic': 'basic',
'tpl-routing': 'routing',
'tpl-outbound': 'outbound',
'tpl-balancer': 'balancer',
'tpl-dns': 'dns',
'tpl-advanced': 'advanced',
};
const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
function onTabChange(key) {
activeTabKey.value = key;
const slug = slugByKey[key];
if (slug && window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function onSaveAll() {
try {
JSON.parse(xraySetting.value);
} catch (e) {
message.error(`Advanced JSON: ${e.message}`);
activeTabKey.value = 'tpl-advanced';
return;
}
saveAll();
}
function syncTabFromHash() {
const key = keyBySlug[window.location.hash.slice(1)];
if (key) activeTabKey.value = key;
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="xray-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-result v-else-if="fetchError" status="error" :title="t('somethingWentWrong')" :sub-title="fetchError">
<template #extra>
<a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
</template>
</a-result>
<template v-else>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<!-- Save / Restart bar -->
<a-col :span="24">
<a-card hoverable>
<a-row class="header-row">
<a-col :xs="24" :sm="14" class="header-actions">
<a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
{{ t('pages.xray.save') }}
</a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
{{ t('pages.xray.restart') }}
</a-button>
<a-popover v-if="restartResult" placement="rightTop">
<template #title>Xray restart output</template>
<template #content>
<pre class="restart-result">{{ restartResult }}</pre>
</template>
<QuestionCircleOutlined class="restart-icon" />
</a-popover>
</a-space>
</a-col>
<a-col :xs="24" :sm="10" class="header-info">
<a-back-top :target="scrollTarget" :visibility-height="200" />
<a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
</a-col>
</a-row>
</a-card>
</a-col>
<!-- Tabs -->
<a-col :span="24">
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
<a-tab-pane key="tpl-basic" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.xray.basicTemplate') : null">
<SettingOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.basicTemplate') }}</span>
</template>
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
:warp-exist="warpExist" :nord-exist="nordExist"
@update:outbound-test-url="(v) => (outboundTestUrl = v)" @show-warp="showWarp"
@show-nord="showNord" @reset-default="resetToDefault" />
</a-tab-pane>
<a-tab-pane key="tpl-routing" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.xray.Routings') : null">
<SwapOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.Routings') }}</span>
</template>
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
</a-tab-pane>
<a-tab-pane key="tpl-outbound" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.xray.Outbounds') : null">
<UploadOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
</template>
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
:inbound-tags="inboundTags" :is-mobile="isMobile"
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
@test-all="onTestAllOutbounds" @delete="onDeleteOutbound"
@show-warp="showWarp" @show-nord="showNord" />
</a-tab-pane>
<a-tab-pane key="tpl-balancer" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.xray.Balancers') : null">
<ClusterOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
</template>
<BalancersTab :template-settings="templateSettings"
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
</a-tab-pane>
<a-tab-pane key="tpl-dns" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? 'DNS' : null">
<DatabaseOutlined />
</a-tooltip>
<span v-if="!isMobile">DNS</span>
</template>
<DnsTab :template-settings="templateSettings" />
</a-tab-pane>
<a-tab-pane key="tpl-advanced" class="tab-pane">
<template #tab>
<a-tooltip :title="isMobile ? t('pages.xray.advancedTemplate') : null">
<CodeOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.advancedTemplate') }}</span>
</template>
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
<a-radio-group v-model:value="advSettings" button-style="solid"
:size="isMobile ? 'small' : 'middle'" :style="{ margin: '12px 0' }">
<a-radio-button value="xraySetting">{{ t('pages.xray.completeTemplate') }}</a-radio-button>
<a-radio-button value="inboundSettings">{{ t('pages.xray.Inbounds') }}</a-radio-button>
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
</a-radio-group>
<JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</template>
</a-spin>
</a-layout-content>
</a-layout>
<WarpModal v-model:open="warpOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
@reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByTag" />
<NordModal v-model:open="nordOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
@reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByIndex"
@remove-routing-rules="onRemoveRoutingRules" />
</a-layout>
</a-config-provider>
</template>
<style scoped>
.xray-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.xray-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.xray-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.xray-page :deep(.ant-layout),
.xray-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
.header-row {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.header-actions {
padding: 4px;
}
.header-info {
display: flex;
justify-content: flex-end;
}
.tab-pane {
padding-top: 20px;
}
.restart-icon {
font-size: 16px;
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
}
.restart-result {
max-width: 480px;
white-space: pre-wrap;
font-size: 12px;
margin: 0;
}
.icons-only :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
.icons-only :deep(.ant-tabs-nav-wrap) {
width: 100%;
}
.icons-only :deep(.ant-tabs-nav-list) {
display: flex;
width: 100%;
}
.icons-only :deep(.ant-tabs-tab) {
flex: 1 1 0;
justify-content: center;
margin: 0;
padding: 10px 0;
}
.icons-only :deep(.ant-tabs-tab .anticon) {
margin: 0;
font-size: 18px;
}
.icons-only :deep(.ant-tabs-nav-operations) {
display: none;
}
</style>
-245
View File
@@ -1,245 +0,0 @@
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { HttpUtil, PromiseUtil } from '@/utils';
const DIRTY_POLL_MS = 1000;
let syncing = false;
export function useXraySetting() {
const fetched = ref(false);
const spinning = ref(false);
const saveDisabled = ref(true);
const fetchError = ref('');
const xraySetting = ref('');
const oldXraySetting = ref('');
const templateSettings = ref(null);
const outboundTestUrl = ref('https://www.google.com/generate_204');
const oldOutboundTestUrl = ref('');
const inboundTags = ref([]);
const clientReverseTags = ref([]);
const restartResult = ref('');
const outboundsTraffic = ref([]);
const outboundTestStates = ref({});
async function fetchAll() {
fetchError.value = '';
const msg = await HttpUtil.post('/panel/xray/');
if (!msg?.success) {
fetchError.value = msg?.msg || 'Failed to load xray config';
fetched.value = true;
return;
}
let obj;
try {
obj = JSON.parse(msg.obj);
} catch (e) {
fetchError.value = `Malformed xray config response: ${e?.message || e}`;
fetched.value = true;
return;
}
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncing = true;
xraySetting.value = pretty;
oldXraySetting.value = pretty;
templateSettings.value = obj.xraySetting;
syncing = false;
inboundTags.value = obj.inboundTags || [];
clientReverseTags.value = obj.clientReverseTags || [];
outboundTestUrl.value = obj.outboundTestUrl || 'https://www.google.com/generate_204';
oldOutboundTestUrl.value = outboundTestUrl.value;
fetched.value = true;
saveDisabled.value = true;
}
watch(
templateSettings,
(next) => {
if (syncing || !next) return;
syncing = true;
try {
xraySetting.value = JSON.stringify(next, null, 2);
} finally {
syncing = false;
}
},
{ deep: true },
);
watch(xraySetting, (next) => {
if (syncing) return;
try {
const parsed = JSON.parse(next);
syncing = true;
try {
templateSettings.value = parsed;
} finally {
syncing = false;
}
} catch (_e) { /* ignore — wait for user to finish */ }
});
async function saveAll() {
spinning.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/update', {
xraySetting: xraySetting.value,
outboundTestUrl: outboundTestUrl.value || 'https://www.google.com/generate_204',
});
if (msg?.success) await fetchAll();
} finally {
spinning.value = false;
}
}
async function fetchOutboundsTraffic() {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
if (msg?.success) outboundsTraffic.value = msg.obj || [];
}
async function resetOutboundsTraffic(tag) {
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
if (msg?.success) await fetchOutboundsTraffic();
}
function applyOutboundsEvent(payload) {
if (Array.isArray(payload)) outboundsTraffic.value = payload;
}
async function testOutbound(index, outbound, mode = 'tcp') {
if (!outbound) return null;
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
outboundTestStates.value[index] = { testing: true, result: null, mode };
try {
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
mode,
});
if (msg?.success) {
outboundTestStates.value[index] = { testing: false, result: msg.obj };
return msg.obj;
}
outboundTestStates.value[index] = {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode },
};
} catch (e) {
outboundTestStates.value[index] = {
testing: false,
result: { success: false, error: String(e), mode },
};
}
return null;
}
const testingAll = ref(false);
async function testAllOutbounds(mode = 'tcp') {
const list = templateSettings.value?.outbounds || [];
if (list.length === 0 || testingAll.value) return;
testingAll.value = true;
try {
const concurrency = mode === 'tcp' ? 8 : 1;
const queue = list
.map((ob, i) => ({ index: i, outbound: ob }))
.filter(({ outbound }) => {
const tag = outbound?.tag;
const proto = outbound?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
return true;
});
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
}
}
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
await Promise.all(workers);
} finally {
testingAll.value = false;
}
}
async function resetToDefault() {
spinning.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
if (msg?.success) {
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
}
} finally {
spinning.value = false;
}
}
async function restartXray() {
spinning.value = true;
try {
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
if (msg?.success) {
// Match legacy: short pause, then poll for the result blob so
// the popover surfaces any startup error from the new process.
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult');
if (r?.success) restartResult.value = r.obj || '';
}
} finally {
spinning.value = false;
}
}
// Same 1s busy-loop pattern the settings page uses — keep it cheap
// and consistent. Real work (the JSON diff) is just a string compare.
let timer = null;
function startDirtyPoll() {
if (timer != null) return;
timer = setInterval(() => {
saveDisabled.value =
oldXraySetting.value === xraySetting.value
&& oldOutboundTestUrl.value === outboundTestUrl.value;
}, DIRTY_POLL_MS);
}
function stopDirtyPoll() {
if (timer != null) {
clearInterval(timer);
timer = null;
}
}
onMounted(() => {
fetchAll();
fetchOutboundsTraffic();
startDirtyPoll();
});
onUnmounted(stopDirtyPoll);
return {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic,
resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
};
}