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.
This commit is contained in:
MHSanaei 2026-05-21 22:03:31 +02:00
parent d50ec74b24
commit ef36757b88
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
25 changed files with 3101 additions and 2517 deletions

View file

@ -8,6 +8,6 @@
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/clients.js"></script>
<script type="module" src="/src/entries/clients.tsx"></script>
</body>
</html>

View file

@ -81,6 +81,18 @@ export default [
caughtErrorsIgnorePattern: '^_',
}],
'no-empty': ['error', { allowEmptyCatch: true }],
// react-hooks v7 introduces three new rules driven by the React
// Compiler. The migration uses several legitimate patterns those
// rules flag (initial-fetch in useEffect, dirty-check derived
// state, `Date.now()` inside derive helpers, inline arrow event
// handlers). We're not running the compiler, so the
// memoization-preservation warnings have no effect on runtime —
// turning them off until the codebase stabilises.
'react-hooks/set-state-in-effect': 'off',
'react-hooks/purity': 'off',
'react-hooks/react-compiler': 'off',
'react-hooks/preserve-manual-memoization': 'off',
},
},
];

View file

@ -0,0 +1,39 @@
// React port of DateTimePicker.vue. For now this delegates to AntD's
// <DatePicker>; the Jalali calendar UI from vue3-persian-datetime-picker
// has no clean React equivalent and is tracked as a follow-up for when
// the inbounds entry migrates. Read-only Jalali display still works via
// IntlUtil.formatDate, which uses Intl.DateTimeFormat with the persian
// calendar extension.
import { DatePicker } from 'antd';
import type { Dayjs } from 'dayjs';
interface DateTimePickerProps {
value: Dayjs | null;
onChange: (next: Dayjs | null) => void;
showTime?: boolean;
format?: string;
placeholder?: string;
disabled?: boolean;
}
export default function DateTimePicker({
value,
onChange,
showTime = true,
format = 'YYYY-MM-DD HH:mm:ss',
placeholder = '',
disabled = false,
}: DateTimePickerProps) {
return (
<DatePicker
value={value}
onChange={(next) => onChange(next || null)}
showTime={showTime ? { format: 'HH:mm:ss' } : false}
format={format}
placeholder={placeholder}
disabled={disabled}
style={{ width: '100%' }}
/>
);
}

View file

@ -1,21 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ClientsPage from '@/pages/clients/ClientsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientsPage from '@/pages/clients/ClientsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<ClientsPage />
</ThemeProvider>,
);
}
});

View file

@ -0,0 +1,282 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
export interface ClientTraffic {
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
lastOnline?: number;
}
export interface ClientRecord {
email: string;
subId?: string;
uuid?: string;
password?: string;
auth?: string;
flow?: string;
totalGB?: number;
expiryTime?: number;
limitIp?: number;
tgId?: number | string;
comment?: string;
enable?: boolean;
inboundIds?: number[];
traffic?: ClientTraffic;
reverse?: { tag?: string };
createdAt?: number;
updatedAt?: number;
[key: string]: unknown;
}
export interface InboundOption {
id: number;
remark?: string;
protocol?: string;
port?: number;
tlsFlowCapable?: boolean;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
export function useClients() {
const [clients, setClients] = useState<ClientRecord[]>([]);
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
});
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [tgBotEnable, setTgBotEnable] = useState(false);
const [expireDiff, setExpireDiff] = useState(0);
const [trafficDiff, setTrafficDiff] = useState(0);
const [pageSize, setPageSize] = useState(0);
const clientsRef = useRef<ClientRecord[]>([]);
const invalidateTimerRef = useRef<number | null>(null);
useEffect(() => { clientsRef.current = clients; }, [clients]);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [clientsMsg, inboundsMsg] = await Promise.all([
HttpUtil.get('/panel/api/clients/list') as Promise<ApiMsg<ClientRecord[]>>,
HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>,
]);
if (clientsMsg?.success) {
setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []);
}
if (inboundsMsg?.success) {
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
}
setFetched(true);
} finally {
setLoading(false);
}
}, []);
const fetchSubSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
if (!msg?.success) return;
const s = msg.obj || {};
setSubSettings({
enable: !!s.subEnable,
subURI: (s.subURI as string) || '',
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable,
});
setIpLimitEnable(!!s.ipLimitEnable);
setTgBotEnable(!!s.tgBotEnable);
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
setPageSize((s.pageSize as number) ?? 0);
}, []);
const create = useCallback(async (payload: unknown) => {
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const update = useCallback(async (email: string, client: unknown) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const remove = useCallback(async (email: string, keepTraffic = false) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const url = keepTraffic
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
: `/panel/api/clients/del/${encoded}`;
const msg = await HttpUtil.post(url) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return [];
const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
}));
await refresh();
return results;
}, [refresh]);
const attach = useCallback(async (email: string, inboundIds: number[]) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const detach = useCallback(async (email: string, inboundIds: number[]) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const resetTraffic = useCallback(async (client: ClientRecord) => {
if (!client?.email) return null;
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
const msg = await HttpUtil.post(url) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const resetAllTraffics = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const delDepleted = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
if (!client?.email) return null;
const payload = {
email: client.email,
subId: client.subId,
id: client.uuid,
password: client.password,
auth: client.auth,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
limitIp: client.limitIp || 0,
comment: client.comment || '',
enable: !!enable,
};
return update(client.email, payload);
}, [update]);
const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[] };
if (Array.isArray(p.onlineClients)) {
setOnlines(p.onlineClients);
}
}, []);
const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
if (row && row.email) byEmail.set(row.email, row);
}
const cur = clientsRef.current || [];
let touched = false;
const next = cur.slice();
for (let i = 0; i < next.length; i++) {
const row = next[i];
const upd = byEmail.get(row?.email);
if (!upd) continue;
const merged: ClientTraffic = { ...(row.traffic || {}) };
if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
next[i] = { ...row, traffic: merged };
touched = true;
}
if (touched) setClients(next);
}, []);
const applyInvalidate = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { type?: string };
if (p.type !== 'inbounds' && p.type !== 'clients') return;
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
invalidateTimerRef.current = window.setTimeout(() => {
invalidateTimerRef.current = null;
refresh();
}, 200);
}, [refresh]);
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
Promise.all([refresh(), fetchSubSettings()]);
/* eslint-enable react-hooks/set-state-in-effect */
}, [refresh, fetchSubSettings]);
return {
clients,
inbounds,
onlines,
loading,
fetched,
subSettings,
ipLimitEnable,
tgBotEnable,
expireDiff,
trafficDiff,
pageSize,
refresh,
create,
update,
remove,
removeMany,
attach,
detach,
resetTraffic,
resetAllTraffics,
delDepleted,
setEnable,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
};
}

View file

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { HttpUtil } from '@/utils';
type Calendar = 'gregorian' | 'jalalian';
let cachedValue: Calendar = 'gregorian';
let fetched = false;
let pending: Promise<void> | null = null;
const listeners = new Set<(value: Calendar) => void>();
function notify(value: Calendar) {
listeners.forEach((fn) => fn(value));
}
async function loadOnce(): Promise<void> {
if (fetched) return;
if (pending) {
await pending;
return;
}
pending = (async () => {
try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
success?: boolean;
obj?: { datepicker?: Calendar };
};
if (msg?.success) {
cachedValue = msg.obj?.datepicker || 'gregorian';
notify(cachedValue);
}
} finally {
fetched = true;
pending = null;
}
})();
await pending;
}
export function setDatepicker(value: Calendar) {
fetched = true;
cachedValue = value || 'gregorian';
notify(cachedValue);
}
export function useDatepicker() {
const [datepicker, setLocal] = useState<Calendar>(cachedValue);
useEffect(() => {
listeners.add(setLocal);
loadOnce();
return () => {
listeners.delete(setLocal);
};
}, []);
return { datepicker };
}

View file

@ -0,0 +1,9 @@
// Slim TS surface for what the React client pages need. The full
// inbound model (StreamSettings, RealityStreamSettings, etc.) still
// lives in inbound.js for the remaining vue entries; this file ports
// only the enum-like constants the React clients page consumes.
export const TLS_FLOW_CONTROL = {
xtls_rprx_vision: 'xtls-rprx-vision',
xtls_rprx_vision_udp443: 'xtls-rprx-vision-udp443',
} as const;

View file

@ -0,0 +1,5 @@
.random-icon {
margin-left: 4px;
cursor: pointer;
color: var(--ant-color-primary, #1677ff);
}

View file

@ -0,0 +1,337 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { SyncOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
import { TLS_FLOW_CONTROL } from '@/models/inbound';
import DateTimePicker from '@/components/DateTimePicker';
import type { InboundOption } from '@/hooks/useClients';
import './ClientBulkAddModal.css';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
]);
interface ApiMsg {
success?: boolean;
msg?: string;
}
interface ClientBulkAddModalProps {
open: boolean;
inbounds: InboundOption[];
ipLimitEnable?: boolean;
onOpenChange: (open: boolean) => void;
onSaved?: () => void;
}
interface FormState {
emailMethod: number;
firstNum: number;
lastNum: number;
emailPrefix: string;
emailPostfix: string;
quantity: number;
subId: string;
comment: string;
flow: string;
limitIp: number;
totalGB: number;
expiryTime: number;
inboundIds: number[];
}
function emptyForm(): FormState {
return {
emailMethod: 0,
firstNum: 1,
lastNum: 1,
emailPrefix: '',
emailPostfix: '',
quantity: 1,
subId: '',
comment: '',
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
inboundIds: [],
};
}
export default function ClientBulkAddModal({
open,
inbounds,
ipLimitEnable = false,
onOpenChange,
onSaved,
}: ClientBulkAddModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(emptyForm);
const [delayedStart, setDelayedStart] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
/* eslint-disable react-hooks/set-state-in-effect */
setForm(emptyForm());
setDelayedStart(false);
/* eslint-enable react-hooks/set-state-in-effect */
}, [open]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
const flowCapableIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row?.tlsFlowCapable) ids.add(row.id);
}
return ids;
}, [inbounds]);
const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds],
);
useEffect(() => {
if (!showFlow && form.flow) {
/* eslint-disable-next-line react-hooks/set-state-in-effect */
update('flow', '');
}
}, [showFlow, form.flow]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
})),
[inbounds],
);
const expiryDate = useMemo<Dayjs | null>(
() => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
[form.expiryTime],
);
const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0;
function buildEmails(): string[] {
const method = form.emailMethod;
const out: string[] = [];
let start: number;
let end: number;
if (method > 1) {
start = form.firstNum;
end = form.lastNum + 1;
} else {
start = 0;
end = form.quantity;
}
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
const useNum = method > 1;
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
for (let i = start; i < end; i++) {
let email = '';
if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
email += useNum ? prefix + String(i) + postfix : prefix + postfix;
out.push(email);
}
return out;
}
async function submit() {
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
message.error(t('pages.clients.selectInbound'));
return;
}
const emails = buildEmails();
if (emails.length === 0) return;
setSaving(true);
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
try {
const results = await Promise.all(emails.map((email) => {
const client = {
email,
subId: form.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(),
password: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: form.expiryTime,
limitIp: Number(form.limitIp) || 0,
comment: form.comment,
enable: true,
};
const payload = { client, inboundIds: form.inboundIds };
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise<ApiMsg>;
}));
let ok = 0;
let failed = 0;
let firstError = '';
for (const msg of results) {
if (msg?.success) ok++;
else {
failed++;
if (!firstError && msg?.msg) firstError = msg.msg;
}
}
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
} else {
message.warning(firstError
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
}
onSaved?.();
onOpenChange(false);
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
title={t('pages.clients.bulk')}
okText={t('create')}
cancelText={t('close')}
confirmLoading={saving}
maskClosable={false}
width={640}
onOk={submit}
onCancel={() => onOpenChange(false)}
>
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<Form.Item label={t('pages.clients.attachedInbounds')} required>
<Select
mode="multiple"
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
showSearch
filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
<Form.Item label={t('pages.clients.method')}>
<Select
value={form.emailMethod}
onChange={(v) => update('emailMethod', v)}
options={[
{ value: 0, label: 'Random' },
{ value: 1, label: 'Random + Prefix' },
{ value: 2, label: 'Random + Prefix + Num' },
{ value: 3, label: 'Random + Prefix + Num + Postfix' },
{ value: 4, label: 'Prefix + Num + Postfix' },
]}
/>
</Form.Item>
{form.emailMethod > 1 && (
<>
<Form.Item label={t('pages.clients.first')}>
<InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
</Form.Item>
<Form.Item label={t('pages.clients.last')}>
<InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
</Form.Item>
</>
)}
{form.emailMethod > 0 && (
<Form.Item label={t('pages.clients.prefix')}>
<Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
</Form.Item>
)}
{form.emailMethod > 2 && (
<Form.Item label={t('pages.clients.postfix')}>
<Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
</Form.Item>
)}
{form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}>
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
</Form.Item>
)}
<Form.Item label={
<>
{t('subscription.title')}
<SyncOutlined
className="random-icon"
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
/>
</>
}>
<Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
</Form.Item>
<Form.Item label={t('comment')}>
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
</Form.Item>
{showFlow && (
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
style={{ width: 220 }}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
)}
{ipLimitEnable && (
<Form.Item label={t('pages.clients.limitIp')}>
<InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
</Form.Item>
)}
<Form.Item label={t('pages.clients.totalGB')}>
<InputNumber value={form.totalGB} min={0} step={0.1} onChange={(v) => update('totalGB', Number(v) || 0)} />
</Form.Item>
<Form.Item label={t('pages.clients.delayedStart')}>
<Switch
checked={delayedStart}
onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
/>
</Form.Item>
{delayedStart ? (
<Form.Item label={t('pages.clients.expireDays')}>
<InputNumber
value={delayedExpireDays}
min={0}
onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
/>
</Form.Item>
) : (
<Form.Item label={t('pages.inbounds.expireDate')}>
<DateTimePicker
value={expiryDate}
onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
/>
</Form.Item>
)}
</Form>
</Modal>
);
}

View file

@ -1,267 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { SyncOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
import DateTimePicker from '@/components/DateTimePicker.vue';
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
inbounds: { type: Array, default: () => [] },
ipLimitEnable: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'saved']);
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const saving = ref(false);
const delayedStart = ref(false);
const form = reactive({
emailMethod: 0,
firstNum: 1,
lastNum: 1,
emailPrefix: '',
emailPostfix: '',
quantity: 1,
subId: '',
comment: '',
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
inboundIds: [],
});
const flowCapableIds = computed(() => {
const ids = new Set();
for (const row of props.inbounds || []) {
if (row?.tlsFlowCapable) ids.add(row.id);
}
return ids;
});
const showFlow = computed(() =>
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
);
watch(showFlow, (next) => {
if (!next) form.flow = '';
});
const expiryDate = computed({
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
});
const delayedExpireDays = computed({
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
});
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
]);
const inboundOptions = computed(() =>
(props.inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
})),
);
watch(() => props.open, (next) => {
if (!next) return;
form.emailMethod = 0;
form.firstNum = 1;
form.lastNum = 1;
form.emailPrefix = '';
form.emailPostfix = '';
form.quantity = 1;
form.subId = '';
form.comment = '';
form.flow = '';
form.limitIp = 0;
form.totalGB = 0;
form.expiryTime = 0;
form.inboundIds = [];
delayedStart.value = false;
});
function close() {
emit('update:open', false);
}
function buildEmails() {
const method = form.emailMethod;
const out = [];
let start;
let end;
if (method > 1) {
start = form.firstNum;
end = form.lastNum + 1;
} else {
start = 0;
end = form.quantity;
}
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
const useNum = method > 1;
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
for (let i = start; i < end; i++) {
let email = '';
if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
email += useNum ? prefix + String(i) + postfix : prefix + postfix;
out.push(email);
}
return out;
}
async function submit() {
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
message.error(t('pages.clients.selectInbound'));
return;
}
const emails = buildEmails();
if (emails.length === 0) return;
saving.value = true;
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
try {
const results = await Promise.all(emails.map((email) => {
const client = {
email,
subId: form.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(),
password: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow.value ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: form.expiryTime,
limitIp: Number(form.limitIp) || 0,
comment: form.comment,
enable: true,
};
const payload = { client, inboundIds: form.inboundIds };
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
}));
let ok = 0;
let failed = 0;
let firstError = '';
for (const msg of results) {
if (msg?.success) ok++;
else {
failed++;
if (!firstError && msg?.msg) firstError = msg.msg;
}
}
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
} else {
message.warning(firstError
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
}
emit('saved');
close();
} finally {
saving.value = false;
}
}
</script>
<template>
<a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" :width="640" @ok="submit" @cancel="close">
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
<a-form-item :label="t('pages.clients.attachedInbounds')" required>
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions"
:placeholder="t('pages.clients.selectInbound')" :show-search="true"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
</a-form-item>
<a-form-item :label="t('pages.clients.method')">
<a-select v-model:value="form.emailMethod">
<a-select-option :value="0">Random</a-select-option>
<a-select-option :value="1">Random + Prefix</a-select-option>
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
<a-input-number v-model:value="form.firstNum" :min="1" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
<a-input v-model:value="form.emailPrefix" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
<a-input v-model:value="form.emailPostfix" />
</a-form-item>
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
<a-input-number v-model:value="form.quantity" :min="1" :max="100" />
</a-form-item>
<a-form-item>
<template #label>
{{ t('subscription.title') }}
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
</template>
<a-input v-model:value="form.subId" />
</a-form-item>
<a-form-item :label="t('comment')">
<a-input v-model:value="form.comment" />
</a-form-item>
<a-form-item v-if="showFlow" :label="t('pages.clients.flow')">
<a-select v-model:value="form.flow" :style="{ width: '220px' }">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp')">
<a-input-number v-model:value="form.limitIp" :min="0" />
</a-form-item>
<a-form-item :label="t('pages.clients.totalGB')">
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item :label="t('pages.clients.delayedStart')">
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
<a-form-item v-else :label="t('pages.inbounds.expireDate')">
<DateTimePicker v-model:value="expiryDate" />
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.random-icon {
margin-left: 4px;
cursor: pointer;
color: var(--ant-color-primary, #1677ff);
}
</style>

View file

@ -0,0 +1 @@
/* Client form modal — additional layout overrides if needed. */

View file

@ -0,0 +1,522 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Tag,
message,
} from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils';
import { TLS_FLOW_CONTROL } from '@/models/inbound';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import './ClientFormModal.css';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
]);
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
type Mode = 'add' | 'edit';
interface SaveMetaEdit {
isEdit: true;
email: string;
attach: number[];
detach: number[];
}
interface SaveMetaCreate {
isEdit: false;
}
interface SaveCreatePayload {
client: Record<string, unknown>;
inboundIds: number[];
}
interface ClientFormModalProps {
open: boolean;
mode: Mode;
client: ClientRecord | null;
inbounds: InboundOption[];
attachedIds?: number[];
ipLimitEnable?: boolean;
tgBotEnable?: boolean;
save: (
payload: Record<string, unknown> | SaveCreatePayload,
meta: SaveMetaEdit | SaveMetaCreate,
) => Promise<ApiMsg | null>;
onOpenChange: (open: boolean) => void;
}
interface FormState {
email: string;
subId: string;
uuid: string;
password: string;
auth: string;
flow: string;
reverseTag: string;
totalGB: number;
expiryDate: Dayjs | null;
delayedStart: boolean;
delayedDays: number;
limitIp: number;
tgId: number;
comment: string;
enable: boolean;
inboundIds: number[];
}
function emptyForm(): FormState {
return {
email: '',
subId: '',
uuid: '',
password: '',
auth: '',
flow: '',
reverseTag: '',
totalGB: 0,
expiryDate: null,
delayedStart: false,
delayedDays: 0,
limitIp: 0,
tgId: 0,
comment: '',
enable: true,
inboundIds: [],
};
}
function bytesToGB(bytes: number): number {
if (!bytes || bytes <= 0) return 0;
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
}
function gbToBytes(gb: number): number {
if (!gb || gb <= 0) return 0;
return Math.round(gb * 1024 * 1024 * 1024);
}
export default function ClientFormModal({
open,
mode,
client,
inbounds,
attachedIds = [],
ipLimitEnable = false,
tgBotEnable = false,
save,
onOpenChange,
}: ClientFormModalProps) {
const { t } = useTranslation();
const isEdit = mode === 'edit';
const [form, setForm] = useState<FormState>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const [clientIps, setClientIps] = useState<string[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
useEffect(() => {
if (!open) return;
/* eslint-disable react-hooks/set-state-in-effect */
if (isEdit && client) {
const et = Number(client.expiryTime) || 0;
const next: FormState = {
...emptyForm(),
email: client.email || '',
subId: client.subId || '',
uuid: client.uuid || '',
password: client.password || '',
auth: client.auth || '',
flow: client.flow || '',
reverseTag: client.reverse?.tag || '',
totalGB: bytesToGB(client.totalGB || 0),
limitIp: client.limitIp || 0,
tgId: Number(client.tgId) || 0,
comment: client.comment || '',
enable: !!client.enable,
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
};
if (et < 0) {
next.delayedStart = true;
next.delayedDays = Math.round(et / -86400000);
next.expiryDate = null;
} else {
next.delayedStart = false;
next.delayedDays = 0;
next.expiryDate = et > 0 ? dayjs(et) : null;
}
setForm(next);
void loadIps();
} else {
setForm({
...emptyForm(),
email: RandomUtil.randomLowerAndNum(9),
uuid: RandomUtil.randomUUID(),
subId: RandomUtil.randomLowerAndNum(16),
password: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
});
}
/* eslint-enable react-hooks/set-state-in-effect */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isEdit]);
const flowCapableIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row?.tlsFlowCapable) ids.add(row.id);
}
return ids;
}, [inbounds]);
const vlessLikeIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'vless') ids.add(row.id);
}
return ids;
}, [inbounds]);
const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds],
);
const showReverseTag = useMemo(
() => (form.inboundIds || []).some((id) => vlessLikeIds.has(id)),
[form.inboundIds, vlessLikeIds],
);
useEffect(() => {
if (!showFlow && form.flow) {
/* eslint-disable-next-line react-hooks/set-state-in-effect */
update('flow', '');
}
}, [showFlow, form.flow]);
useEffect(() => {
if (!showReverseTag && form.reverseTag) {
/* eslint-disable-next-line react-hooks/set-state-in-effect */
update('reverseTag', '');
}
}, [showReverseTag, form.reverseTag]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
})),
[inbounds],
);
async function loadIps() {
if (!isEdit || !client?.email) return;
setIpsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
} finally {
setIpsLoading(false);
}
}
async function clearIps() {
if (!isEdit || !client?.email) return;
setIpsClearing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
if (msg?.success) setClientIps([]);
} finally {
setIpsClearing(false);
}
}
function close() {
onOpenChange(false);
}
async function onSubmit() {
if (!form.email || form.email.trim() === '') {
message.error(`${t('pages.clients.email')} *`);
return;
}
if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) {
message.error(t('pages.clients.selectInbound'));
return;
}
const expiryTime = form.delayedStart
? -86400000 * (Number(form.delayedDays) || 0)
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
const clientPayload: Record<string, unknown> = {
email: form.email.trim(),
subId: form.subId,
id: form.uuid,
password: form.password,
auth: form.auth,
flow: showFlow ? (form.flow || '') : '',
totalGB: gbToBytes(form.totalGB),
expiryTime,
limitIp: Number(form.limitIp) || 0,
tgId: Number(form.tgId) || 0,
comment: form.comment,
enable: !!form.enable,
};
const reverseTag = showReverseTag ? (form.reverseTag || '').trim() : '';
if (reverseTag) {
clientPayload.reverse = { tag: reverseTag };
}
setSubmitting(true);
try {
let msg;
if (isEdit && client) {
const original = new Set(attachedIds || []);
const next = new Set(form.inboundIds || []);
const toAttach = [...next].filter((id) => !original.has(id));
const toDetach = [...original].filter((id) => !next.has(id));
msg = await save(clientPayload, {
isEdit: true,
email: client.email,
attach: toAttach,
detach: toDetach,
});
} else {
msg = await save(
{ client: clientPayload, inboundIds: form.inboundIds },
{ isEdit: false },
);
}
if (msg?.success) close();
} finally {
setSubmitting(false);
}
}
return (
<Modal
open={open}
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
destroyOnClose
okText={isEdit ? t('save') : t('create')}
cancelText={t('cancel')}
okButtonProps={{ loading: submitting }}
width={720}
onOk={onSubmit}
onCancel={close}
>
<Form layout="vertical">
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.email')} required>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={form.email}
placeholder={t('pages.clients.email')}
style={{ flex: 1 }}
onChange={(e) => update('email', e.target.value)}
/>
<Button onClick={() => update('email', RandomUtil.randomLowerAndNum(12))}></Button>
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}></Button>
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))}></Button>
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button onClick={() => update('password', RandomUtil.randomLowerAndNum(16))}></Button>
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button onClick={() => update('uuid', RandomUtil.randomUUID())}></Button>
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={ipLimitEnable ? 8 : 12}>
<Form.Item label={t('pages.clients.totalGB')}>
<InputNumber value={form.totalGB} min={0} step={0.1} style={{ width: '100%' }}
onChange={(v) => update('totalGB', Number(v) || 0)} />
</Form.Item>
</Col>
{ipLimitEnable && (
<Col xs={24} md={4}>
<Form.Item label={t('pages.clients.limitIp')}>
<InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
onChange={(v) => update('limitIp', Number(v) || 0)} />
</Form.Item>
</Col>
)}
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
{form.delayedStart ? (
<Form.Item label={t('pages.clients.expireDays')}>
<InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
onChange={(v) => update('delayedDays', Number(v) || 0)} />
</Form.Item>
) : (
<Form.Item label={t('pages.clients.expiryTime')}>
<DatePicker
value={form.expiryDate}
onChange={(d) => update('expiryDate', d || null)}
showTime
style={{ width: '100%' }}
/>
</Form.Item>
)}
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.delayedStart')}>
<Switch
checked={form.delayedStart}
onChange={(v) => {
update('delayedStart', v);
if (v) update('expiryDate', null);
else update('delayedDays', 0);
}}
/>
</Form.Item>
</Col>
</Row>
{(showFlow || showReverseTag) && (
<Row gutter={16}>
{showFlow && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Col>
)}
{showReverseTag && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.reverseTag')}>
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
onChange={(e) => update('reverseTag', e.target.value)} />
</Form.Item>
</Col>
)}
</Row>
)}
<Row gutter={16}>
{tgBotEnable && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.telegramId')}>
<InputNumber value={form.tgId} min={0} controls={false}
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
onChange={(v) => update('tgId', Number(v) || 0)} />
</Form.Item>
</Col>
)}
<Col xs={24} md={tgBotEnable ? 12 : 24}>
<Form.Item label={t('pages.clients.comment')}>
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
</Form.Item>
</Col>
</Row>
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
<Select
mode="multiple"
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
options={inboundOptions}
showSearch
placeholder={t('pages.clients.selectInbound')}
filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
<Form.Item>
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
</Form.Item>
{isEdit && ipLimitEnable && (
<Form.Item label={t('pages.clients.ipLog')}>
<Space style={{ marginBottom: 8 }}>
<Button size="small" loading={ipsLoading} onClick={loadIps}>{t('refresh')}</Button>
<Button size="small" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
{t('pages.clients.clearAll')}
</Button>
</Space>
{clientIps.length > 0 ? (
<div>
{clientIps.map((ip, idx) => (
<Tag key={idx} color="blue" style={{ marginBottom: 4 }}>{ip}</Tag>
))}
</div>
) : (
<Tag>{t('tgbot.noIpRecord')}</Tag>
)}
</Form.Item>
)}
</Form>
</Modal>
);
}

View file

@ -1,402 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils';
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'add' },
client: { type: Object, default: null },
inbounds: { type: Array, default: () => [] },
attachedIds: { type: Array, default: () => [] },
ipLimitEnable: { type: Boolean, default: false },
tgBotEnable: { type: Boolean, default: false },
save: { type: Function, required: true },
});
const emit = defineEmits(['update:open']);
const { t } = useI18n();
const submitting = ref(false);
const form = reactive(emptyForm());
function emptyForm() {
return {
email: '',
subId: '',
uuid: '',
password: '',
auth: '',
flow: '',
reverseTag: '',
totalGB: 0,
expiryDate: null,
delayedStart: false,
delayedDays: 0,
limitIp: 0,
tgId: 0,
comment: '',
enable: true,
inboundIds: [],
};
}
const isEdit = computed(() => props.mode === 'edit');
watch(
() => props.open,
(next) => {
if (!next) return;
Object.assign(form, emptyForm());
if (isEdit.value && props.client) {
form.email = props.client.email || '';
form.subId = props.client.subId || '';
form.uuid = props.client.uuid || '';
form.password = props.client.password || '';
form.auth = props.client.auth || '';
form.flow = props.client.flow || '';
form.reverseTag = props.client.reverse?.tag || '';
form.totalGB = bytesToGB(props.client.totalGB || 0);
const et = Number(props.client.expiryTime) || 0;
if (et < 0) {
form.delayedStart = true;
form.delayedDays = Math.round(et / -86400000);
form.expiryDate = null;
} else {
form.delayedStart = false;
form.delayedDays = 0;
form.expiryDate = et > 0 ? dayjs(et) : null;
}
form.limitIp = props.client.limitIp || 0;
form.tgId = Number(props.client.tgId) || 0;
form.comment = props.client.comment || '';
form.enable = !!props.client.enable;
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
void loadIps();
} else {
form.email = RandomUtil.randomLowerAndNum(9);
form.uuid = RandomUtil.randomUUID();
form.subId = RandomUtil.randomLowerAndNum(16);
form.password = RandomUtil.randomLowerAndNum(16);
form.auth = RandomUtil.randomLowerAndNum(16);
}
},
);
function bytesToGB(bytes) {
if (!bytes || bytes <= 0) return 0;
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
}
function gbToBytes(gb) {
if (!gb || gb <= 0) return 0;
return Math.round(gb * 1024 * 1024 * 1024);
}
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
]);
const inboundOptions = computed(() =>
(props.inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
})),
);
const flowCapableIds = computed(() => {
const ids = new Set();
for (const row of props.inbounds || []) {
if (row?.tlsFlowCapable) ids.add(row.id);
}
return ids;
});
const showFlow = computed(() =>
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
);
watch(showFlow, (next) => {
if (!next) form.flow = '';
});
const vlessLikeIds = computed(() => {
const ids = new Set();
for (const row of props.inbounds || []) {
if (row && row.protocol === 'vless') {
ids.add(row.id);
}
}
return ids;
});
const showReverseTag = computed(() =>
(form.inboundIds || []).some((id) => vlessLikeIds.value.has(id)),
);
watch(showReverseTag, (next) => {
if (!next) form.reverseTag = '';
});
const clientIps = ref([]);
const ipsLoading = ref(false);
const ipsClearing = ref(false);
async function loadIps() {
if (!isEdit.value || !props.client?.email) return;
ipsLoading.value = true;
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
if (!msg?.success) { clientIps.value = []; return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
} finally {
ipsLoading.value = false;
}
}
async function clearIps() {
if (!isEdit.value || !props.client?.email) return;
ipsClearing.value = true;
try {
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
if (msg?.success) clientIps.value = [];
} finally {
ipsClearing.value = false;
}
}
function close() {
emit('update:open', false);
}
function regenerateUUID() {
form.uuid = RandomUtil.randomUUID();
}
function regeneratePassword() {
form.password = RandomUtil.randomLowerAndNum(16);
}
function regenerateAuth() {
form.auth = RandomUtil.randomLowerAndNum(16);
}
function regenerateSubId() {
form.subId = RandomUtil.randomLowerAndNum(16);
}
function regenerateEmail() {
form.email = RandomUtil.randomLowerAndNum(12);
}
function onDelayedStartToggle(next) {
if (next) {
form.expiryDate = null;
} else {
form.delayedDays = 0;
}
}
async function onSubmit() {
if (!form.email || form.email.trim() === '') {
message.error(`${t('pages.clients.email')} *`);
return;
}
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
message.error(t('pages.clients.selectInbound'));
return;
}
const expiryTime = form.delayedStart
? -86400000 * (Number(form.delayedDays) || 0)
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
const clientPayload = {
email: form.email.trim(),
subId: form.subId,
id: form.uuid,
password: form.password,
auth: form.auth,
flow: showFlow.value ? (form.flow || '') : '',
totalGB: gbToBytes(form.totalGB),
expiryTime,
limitIp: Number(form.limitIp) || 0,
tgId: Number(form.tgId) || 0,
comment: form.comment,
enable: !!form.enable,
};
const reverseTag = showReverseTag.value ? (form.reverseTag || '').trim() : '';
if (reverseTag) {
clientPayload.reverse = { tag: reverseTag };
}
submitting.value = true;
try {
let msg;
if (isEdit.value) {
const original = new Set(props.attachedIds || []);
const next = new Set(form.inboundIds || []);
const toAttach = [...next].filter((id) => !original.has(id));
const toDetach = [...original].filter((id) => !next.has(id));
msg = await props.save(clientPayload, {
isEdit: true,
email: props.client.email,
attach: toAttach,
detach: toDetach,
});
} else {
msg = await props.save(
{ client: clientPayload, inboundIds: form.inboundIds },
{ isEdit: false },
);
}
if (msg?.success) close();
} finally {
submitting.value = false;
}
}
</script>
<template>
<a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item :label="t('pages.clients.email')" required>
<a-input-group compact style="display: flex">
<a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" style="flex: 1" />
<a-button @click="regenerateEmail"></a-button>
</a-input-group>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="t('pages.clients.subId')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.subId" style="flex: 1" />
<a-button @click="regenerateSubId"></a-button>
</a-input-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item :label="t('pages.clients.hysteriaAuth')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.auth" style="flex: 1" />
<a-button @click="regenerateAuth"></a-button>
</a-input-group>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="t('pages.clients.password')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.password" style="flex: 1" />
<a-button @click="regeneratePassword"></a-button>
</a-input-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item :label="t('pages.clients.uuid')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.uuid" style="flex: 1" />
<a-button @click="regenerateUUID"></a-button>
</a-input-group>
</a-form-item>
</a-col>
<a-col :xs="24" :md="ipLimitEnable ? 8 : 12">
<a-form-item :label="t('pages.clients.totalGB')">
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="ipLimitEnable" :xs="24" :md="4">
<a-form-item :label="t('pages.clients.limitIp')">
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
<a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item v-else :label="t('pages.clients.expiryTime')">
<a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="t('pages.clients.delayedStart')">
<a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
</a-form-item>
</a-col>
</a-row>
<a-row v-if="showFlow || showReverseTag" :gutter="16">
<a-col v-if="showFlow" :xs="24" :md="12">
<a-form-item :label="t('pages.clients.flow')">
<a-select v-model:value="form.flow">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="showReverseTag" :xs="24" :md="12">
<a-form-item :label="t('pages.clients.reverseTag')">
<a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col v-if="tgBotEnable" :xs="24" :md="12">
<a-form-item :label="t('pages.clients.telegramId')">
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
:placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="tgBotEnable ? 12 : 24">
<a-form-item :label="t('pages.clients.comment')">
<a-input v-model:value="form.comment" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('pages.clients.attachedInbounds')" :required="!isEdit">
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
:placeholder="t('pages.clients.selectInbound')"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
</a-form-item>
<a-form-item>
<a-switch v-model:checked="form.enable" />
<span style="margin-left: 8px">{{ t('enable') }}</span>
</a-form-item>
<a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
<a-space style="margin-bottom: 8px">
<a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
<a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
{{ t('pages.clients.clearAll') }}
</a-button>
</a-space>
<div v-if="clientIps.length > 0">
<a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
</div>
<a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
</a-form-item>
</a-form>
</a-modal>
</template>

View file

@ -0,0 +1,98 @@
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table.block {
margin-bottom: 10px;
}
.info-table td {
padding: 4px 8px;
vertical-align: top;
}
.info-table td:first-child {
width: 140px;
font-size: 13px;
opacity: 0.75;
white-space: nowrap;
}
.info-large-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.hint {
font-size: 12px;
opacity: 0.55;
margin-left: 6px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.link-panel {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.link-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link-panel-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
}
body.dark .link-panel-text {
background: rgba(255, 255, 255, 0.05);
}
.link-panel-anchor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
color: var(--ant-color-primary, #1677ff);
text-decoration: underline;
text-decoration-color: rgba(22, 119, 255, 0.4);
transition: background 120ms ease, text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration-color: var(--ant-color-primary, #1677ff);
}
body.dark .link-panel-anchor {
background: rgba(255, 255, 255, 0.05);
}
body.dark .link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.16);
}

View file

@ -0,0 +1,294 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Tag, Tooltip, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import './ClientInfoModal.css';
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
interface ClientInfoModalProps {
open: boolean;
client: ClientRecord | null;
inboundsById: Record<number, InboundOption>;
isOnline: boolean;
subSettings?: SubSettings;
onOpenChange: (open: boolean) => void;
}
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
function expiryLabel(ts?: number) {
if (!ts || ts <= 0) return '∞';
return IntlUtil.formatDate(ts);
}
function dateLabel(ts?: number) {
if (!ts || ts <= 0) return '-';
return IntlUtil.formatDate(ts);
}
export default function ClientInfoModal({
open,
client,
inboundsById,
isOnline,
subSettings = DEFAULT_SUB,
onOpenChange,
}: ClientInfoModalProps) {
const { t } = useTranslation();
const [links, setLinks] = useState<string[]>([]);
useEffect(() => {
if (!open) {
setLinks([]);
return;
}
if (!client?.subId) return;
let cancelled = false;
(async () => {
const msg = await HttpUtil.get(
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
) as ApiMsg<string[]>;
if (cancelled) return;
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, [open, client?.subId]);
const traffic = client?.traffic || null;
const totalBytes = client?.totalGB || 0;
const used = (traffic?.up || 0) + (traffic?.down || 0);
const remaining = useMemo(() => {
if (totalBytes <= 0) return -1;
const r = totalBytes - used;
return r > 0 ? r : 0;
}, [totalBytes, used]);
const subLink = useMemo(() => {
if (!client?.subId || !subSettings?.subURI) return '';
return subSettings.subURI + client.subId;
}, [client?.subId, subSettings?.subURI]);
const subJsonLink = useMemo(() => {
if (!client?.subId) return '';
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
const showSubscription = !!(subSettings?.enable && client?.subId);
async function copyValue(text: string) {
if (!text) return;
const ok = await ClipboardManager.copyText(String(text));
if (ok) message.success(t('copied'));
}
return (
<Modal
open={open}
title={client ? client.email : t('info')}
footer={null}
width={640}
onCancel={() => onOpenChange(false)}
>
{client && (
<>
<table className="info-table block">
<tbody>
<tr>
<td>{t('pages.clients.online')}</td>
<td>
{client.enable && isOnline
? <Tag color="green">{t('pages.clients.online')}</Tag>
: <Tag>{t('pages.clients.offline')}</Tag>}
<span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
</td>
</tr>
<tr>
<td>{t('status')}</td>
<td>
<Tag color={client.enable ? 'green' : 'default'}>
{client.enable ? t('enabled') : t('disabled')}
</Tag>
</td>
</tr>
<tr>
<td>{t('pages.clients.email')}</td>
<td>
{client.email
? <Tag color="green">{client.email}</Tag>
: <Tag color="red">{t('none')}</Tag>}
</td>
</tr>
<tr>
<td>{t('pages.clients.subId')}</td>
<td>
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
{client.subId && (
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
)}
</td>
</tr>
{client.uuid && (
<tr>
<td>{t('pages.clients.uuid')}</td>
<td>
<Tag className="info-large-tag">{client.uuid}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
</td>
</tr>
)}
{client.password && (
<tr>
<td>{t('password')}</td>
<td>
<Tag className="info-large-tag">{client.password}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
</td>
</tr>
)}
{client.auth && (
<tr>
<td>{t('pages.clients.auth')}</td>
<td>
<Tag className="info-large-tag">{client.auth}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
</td>
</tr>
)}
<tr>
<td>{t('pages.clients.flow')}</td>
<td>
{client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
</td>
</tr>
<tr>
<td>{t('pages.inbounds.traffic')}</td>
<td>
<Tag>
{SizeFormatter.sizeFormat(traffic?.up || 0)}
{' '}/ {SizeFormatter.sizeFormat(traffic?.down || 0)}
</Tag>
<span className="hint">
{SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
</span>
</td>
</tr>
<tr>
<td>{t('remained')}</td>
<td>
{remaining < 0
? <Tag color="purple"></Tag>
: <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
</td>
</tr>
<tr>
<td>{t('pages.inbounds.expireDate')}</td>
<td>
{!client.expiryTime || client.expiryTime <= 0
? <Tag color="purple"></Tag>
: <Tag>{expiryLabel(client.expiryTime)}</Tag>}
{(client.expiryTime ?? 0) > 0 && (
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
)}
</td>
</tr>
<tr>
<td>{t('pages.clients.ipLimit')}</td>
<td>{!client.limitIp ? <Tag></Tag> : <Tag>{client.limitIp}</Tag>}</td>
</tr>
<tr>
<td>{t('pages.inbounds.createdAt')}</td>
<td><Tag>{dateLabel(client.createdAt)}</Tag></td>
</tr>
<tr>
<td>{t('pages.inbounds.updatedAt')}</td>
<td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
</tr>
{client.comment && (
<tr>
<td>{t('pages.clients.comment')}</td>
<td><Tag className="info-large-tag">{client.comment}</Tag></td>
</tr>
)}
<tr>
<td>{t('pages.clients.attachedInbounds')}</td>
<td>
<div className="chips">
{(client.inboundIds || []).map((id) => {
const ib = inboundsById[id];
return (
<Tag key={id} color="blue">
{ib ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})` : `#${id}`}
</Tag>
);
})}
{(!client.inboundIds || client.inboundIds.length === 0) && (
<span className="hint"></span>
)}
</div>
</td>
</tr>
</tbody>
</table>
{links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => (
<div key={idx} className="link-panel">
<div className="link-panel-header">
<Tag color="green">{`${t('pages.clients.link')} ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
</Tooltip>
</div>
<code className="link-panel-text">{link}</code>
</div>
))}
</>
)}
{showSubscription && subLink && (
<>
<Divider>{t('subscription.title')}</Divider>
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">{t('subscription.title')}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
</Tooltip>
</div>
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
</div>
{subJsonLink && (
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">JSON</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
</Tooltip>
</div>
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
</div>
)}
</>
)}
</>
)}
</Modal>
);
}

View file

@ -1,411 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { CopyOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { SizeFormatter, IntlUtil, ClipboardManager, HttpUtil } from '@/utils';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
client: { type: Object, default: null },
inboundsById: { type: Object, default: () => ({}) },
isOnline: { type: Boolean, default: false },
subSettings: {
type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
},
});
const emit = defineEmits(['update:open']);
const links = ref([]);
const linksLoading = ref(false);
const traffic = computed(() => props.client?.traffic || null);
const totalBytes = computed(() => props.client?.totalGB || 0);
const used = computed(() => (traffic.value?.up || 0) + (traffic.value?.down || 0));
const remaining = computed(() => {
if (totalBytes.value <= 0) return -1;
const r = totalBytes.value - used.value;
return r > 0 ? r : 0;
});
const subLink = computed(() => {
if (!props.client?.subId || !props.subSettings?.subURI) return '';
return props.subSettings.subURI + props.client.subId;
});
const subJsonLink = computed(() => {
if (!props.client?.subId) return '';
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
return props.subSettings.subJsonURI + props.client.subId;
});
const showSubscription = computed(
() => !!(props.subSettings?.enable && props.client?.subId),
);
function expiryLabel(ts) {
if (!ts || ts <= 0) return '∞';
return IntlUtil.formatDate(ts);
}
function expiryRelative(ts) {
if (!ts || ts <= 0) return '';
return IntlUtil.formatRelativeTime(ts);
}
function lastOnlineLabel(ts) {
if (!ts || ts <= 0) return '-';
return IntlUtil.formatDate(ts);
}
function dateLabel(ts) {
if (!ts || ts <= 0) return '-';
return IntlUtil.formatDate(ts);
}
async function copyValue(text) {
if (!text) return;
const ok = await ClipboardManager.copyText(String(text));
if (ok) message.success(t('copied'));
}
async function loadLinks() {
if (!props.client?.subId) {
links.value = [];
return;
}
linksLoading.value = true;
try {
const msg = await HttpUtil.get(
`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
);
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
} finally {
linksLoading.value = false;
}
}
watch(() => props.open, (next) => {
if (next) loadLinks();
else links.value = [];
});
function close() {
emit('update:open', false);
}
</script>
<template>
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="640" @cancel="close">
<template v-if="client">
<table class="info-table block">
<tbody>
<tr>
<td>{{ t('pages.clients.online') }}</td>
<td>
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
<a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
<span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
</td>
</tr>
<tr>
<td>{{ t('status') }}</td>
<td>
<a-tag :color="client.enable ? 'green' : 'default'">
{{ client.enable ? t('enabled') : t('disabled') }}
</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.email') }}</td>
<td>
<a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
<a-tag v-else color="red">{{ t('none') }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.subId') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
<a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr v-if="client.uuid">
<td>{{ t('pages.clients.uuid') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.uuid)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr v-if="client.password">
<td>{{ t('password') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.password }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.password)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr v-if="client.auth">
<td>{{ t('pages.clients.auth') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.auth }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.auth)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.flow') }}</td>
<td>
<a-tag v-if="client.flow">{{ client.flow }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.traffic') }}</td>
<td>
<a-tag>
{{ SizeFormatter.sizeFormat(traffic?.up || 0) }}
/ {{ SizeFormatter.sizeFormat(traffic?.down || 0) }}
</a-tag>
<span class="hint">
{{ SizeFormatter.sizeFormat(used) }}
/
{{ totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞' }}
</span>
</td>
</tr>
<tr>
<td>{{ t('remained') }}</td>
<td>
<a-tag v-if="remaining < 0" color="purple"></a-tag>
<a-tag v-else :color="remaining > 0 ? '' : 'red'">
{{ SizeFormatter.sizeFormat(remaining) }}
</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.expireDate') }}</td>
<td>
<a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple"></a-tag>
<a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
<span v-if="client.expiryTime > 0" class="hint">{{ expiryRelative(client.expiryTime) }}</span>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.ipLimit') }}</td>
<td>
<a-tag v-if="!client.limitIp"></a-tag>
<a-tag v-else>{{ client.limitIp }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.createdAt') }}</td>
<td>
<a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.updatedAt') }}</td>
<td>
<a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
</td>
</tr>
<tr v-if="client.comment">
<td>{{ t('pages.clients.comment') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.comment }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.attachedInbounds') }}</td>
<td>
<div class="chips">
<a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
<template v-if="inboundsById[id]">
{{ inboundsById[id].remark || `#${id}` }} ({{ inboundsById[id].protocol }}:{{ inboundsById[id].port }})
</template>
<template v-else>#{{ id }}</template>
</a-tag>
<span v-if="!client.inboundIds || client.inboundIds.length === 0" class="hint"></span>
</div>
</td>
</tr>
</tbody>
</table>
<template v-if="links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ `${t('pages.clients.link')} ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(link)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<code class="link-panel-text">{{ link }}</code>
</div>
</template>
<template v-if="showSubscription && subLink">
<a-divider>{{ t('subscription.title') }}</a-divider>
<div class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ t('subscription.title') }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(subLink)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
</div>
<div v-if="subJsonLink" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">JSON</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(subJsonLink)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink }}</a>
</div>
</template>
</template>
</a-modal>
</template>
<style scoped>
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table.block {
margin-bottom: 10px;
}
.info-table td {
padding: 4px 8px;
vertical-align: top;
}
.info-table td:first-child {
width: 140px;
font-size: 13px;
opacity: 0.75;
white-space: nowrap;
}
.info-large-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.hint {
font-size: 12px;
opacity: 0.55;
margin-left: 6px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.link-panel {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.link-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link-panel-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
}
:global(body.dark) .link-panel-text {
background: rgba(255, 255, 255, 0.05);
}
.link-panel-anchor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
color: var(--ant-color-primary, #1677ff);
text-decoration: underline;
text-decoration-color: rgba(22, 119, 255, 0.4);
transition: background 120ms ease, text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration-color: var(--ant-color-primary, #1677ff);
}
:global(body.dark) .link-panel-anchor {
background: rgba(255, 255, 255, 0.05);
}
:global(body.dark) .link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.16);
}
</style>

View file

@ -0,0 +1,128 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Modal, Spin } from 'antd';
import { HttpUtil } from '@/utils';
import QrPanel from '@/pages/inbounds/QrPanel';
import type { ClientRecord } from '@/hooks/useClients';
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
interface ClientQrModalProps {
open: boolean;
client: ClientRecord | null;
subSettings?: SubSettings;
onOpenChange: (open: boolean) => void;
}
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
export default function ClientQrModal({
open,
client,
subSettings = DEFAULT_SUB,
onOpenChange,
}: ClientQrModalProps) {
const { t } = useTranslation();
const [links, setLinks] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const subLink = useMemo(() => {
if (!client?.subId || !subSettings?.enable || !subSettings?.subURI) return '';
return subSettings.subURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subURI]);
const subJsonLink = useMemo(() => {
if (!client?.subId || !subSettings?.enable) return '';
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
const hasAnything = !!subLink || !!subJsonLink || links.length > 0;
useEffect(() => {
if (!open || !client?.subId) {
setLinks([]);
return;
}
let cancelled = false;
setLoading(true);
(async () => {
try {
const msg = await HttpUtil.get(
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
) as ApiMsg<string[]>;
if (!cancelled) {
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [open, client?.subId]);
const activeKeys = useMemo(() => {
const keys: string[] = [];
if (subLink) keys.push('sub');
if (subJsonLink) keys.push('subJson');
if (links.length > 0) keys.push('l0');
return keys;
}, [subLink, subJsonLink, links.length]);
const items = useMemo(() => {
const out: { key: string; label: string; children: React.ReactNode }[] = [];
if (subLink) {
out.push({
key: 'sub',
label: t('subscription.title'),
children: <QrPanel value={subLink} remark={`${client?.email || ''}${t('subscription.title')}`} />,
});
}
if (subJsonLink) {
out.push({
key: 'subJson',
label: `${t('subscription.title')} (JSON)`,
children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
});
}
links.forEach((link, idx) => {
out.push({
key: `l${idx}`,
label: `${t('pages.clients.link')} ${idx + 1}`,
children: <QrPanel value={link} remark={`${client?.email || ''} #${idx + 1}`} />,
});
});
return out;
}, [subLink, subJsonLink, links, client?.email, t]);
return (
<Modal
open={open}
title={client ? client.email : t('qrCode')}
footer={null}
width={520}
centered
onCancel={() => onOpenChange(false)}
>
<Spin spinning={loading}>
{!client?.subId && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noSubId')}</div>
)}
{client?.subId && !hasAnything && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noLinks')}</div>
)}
{hasAnything && <Collapse activeKey={activeKeys} accordion={false} items={items} />}
</Spin>
</Modal>
);
}

View file

@ -1,97 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil } from '@/utils';
import QrPanel from '@/pages/inbounds/QrPanel.vue';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
client: { type: Object, default: null },
subSettings: {
type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
},
});
const emit = defineEmits(['update:open']);
const links = ref([]);
const loading = ref(false);
const subLink = computed(() => {
if (!props.client?.subId || !props.subSettings?.enable || !props.subSettings?.subURI) return '';
return props.subSettings.subURI + props.client.subId;
});
const subJsonLink = computed(() => {
if (!props.client?.subId || !props.subSettings?.enable) return '';
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
return props.subSettings.subJsonURI + props.client.subId;
});
const activeKeys = computed(() => {
const keys = [];
if (subLink.value) keys.push('sub');
if (subJsonLink.value) keys.push('subJson');
if (links.value.length > 0) keys.push('l0');
return keys;
});
const hasAnything = computed(
() => !!subLink.value || !!subJsonLink.value || links.value.length > 0,
);
watch(() => props.open, async (next) => {
if (!next || !props.client?.subId) {
links.value = [];
return;
}
loading.value = true;
try {
const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
} finally {
loading.value = false;
}
});
function close() {
emit('update:open', false);
}
</script>
<template>
<a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
@cancel="close">
<a-spin :spinning="loading">
<div v-if="!client?.subId && !loading" class="empty">
{{ t('pages.clients.noSubId') }}
</div>
<div v-else-if="!hasAnything && !loading" class="empty">
{{ t('pages.clients.noLinks') }}
</div>
<a-collapse v-else :active-key="activeKeys" accordion>
<a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
<QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
</a-collapse-panel>
<a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
<QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
</a-collapse-panel>
<a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
:header="`${t('pages.clients.link')} ${idx + 1}`">
<QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
</a-collapse-panel>
</a-collapse>
</a-spin>
</a-modal>
</template>
<style scoped>
.empty {
padding: 24px;
text-align: center;
opacity: 0.6;
}
</style>

View file

@ -0,0 +1,221 @@
.clients-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.clients-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.clients-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.clients-page .ant-layout,
.clients-page .ant-layout-content {
background: transparent;
}
.clients-page .content-shell {
background: transparent;
}
.clients-page .content-area {
padding: 24px;
}
@media (max-width: 768px) {
.clients-page .content-area {
padding: 8px;
}
}
.clients-page .ant-pagination-options-size-changer,
.clients-page .ant-pagination-options-size-changer .ant-select-selector {
min-width: 100px !important;
}
.clients-page .loading-spacer {
min-height: calc(100vh - 120px);
}
.clients-page .summary-card {
padding: 16px;
}
@media (max-width: 768px) {
.clients-page .summary-card {
padding: 8px;
}
}
.client-email-list {
max-height: 280px;
min-width: 160px;
overflow-y: auto;
padding-right: 4px;
}
.client-email-list > div {
padding: 2px 0;
font-size: 12px;
white-space: nowrap;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.filter-bar.mobile {
gap: 6px;
margin-bottom: 8px;
}
.filter-bar.mobile > * {
flex: 0 0 auto;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.dot-green { background: #52c41a; }
.dot-blue { background: #1677ff; }
.dot-red { background: #ff4d4f; }
.dot-orange { background: #fa8c16; }
.dot-gray { background: rgba(128, 128, 128, 0.6); }
.status-tag {
margin: 0 0 0 4px;
font-size: 11px;
padding: 0 6px;
line-height: 18px;
}
.card-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.email-cell {
display: flex;
flex-direction: column;
}
.email-cell .email {
font-weight: 500;
}
.email-cell .sub {
font-size: 11px;
opacity: 0.55;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.client-cards {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
}
.card-bulk-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px 8px;
}
.bulk-count {
font-size: 12px;
background: rgba(22, 119, 255, 0.12);
color: var(--ant-color-primary, #1677ff);
padding: 1px 8px;
border-radius: 10px;
}
.client-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
}
.client-card.is-selected {
border-color: var(--ant-color-primary, #1677ff);
background: rgba(22, 119, 255, 0.06);
}
body.dark .client-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.card-head .tag-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.row-action-trigger {
font-size: 18px;
cursor: pointer;
opacity: 0.75;
transition: opacity 120ms ease;
}
.row-action-trigger:hover {
opacity: 1;
}
.card-empty {
text-align: center;
padding: 40px 16px;
opacity: 0.55;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.clients-empty {
padding: 32px 0;
text-align: center;
opacity: 0.55;
}

View file

@ -0,0 +1,899 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Checkbox,
Col,
ConfigProvider,
Dropdown,
Input,
Layout,
Modal,
Popover,
Radio,
Row,
Select,
Space,
Spin,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import {
DeleteOutlined,
EditOutlined,
FilterOutlined,
InfoCircleOutlined,
MoreOutlined,
PlusOutlined,
QrcodeOutlined,
RestOutlined,
RetweetOutlined,
SearchOutlined,
TeamOutlined,
UserOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils';
import ClientFormModal from './ClientFormModal';
import ClientInfoModal from './ClientInfoModal';
import ClientQrModal from './ClientQrModal';
import ClientBulkAddModal from './ClientBulkAddModal';
import './ClientsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const FILTER_STATE_KEY = 'clientsFilterState';
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface FilterState {
enableFilter: boolean;
searchKey: string;
filterBy: string;
protocolFilter?: string;
}
function readFilterState(): FilterState {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
return {
enableFilter: !!raw.enableFilter,
searchKey: raw.searchKey || '',
filterBy: raw.filterBy || '',
protocolFilter: raw.protocolFilter,
};
} catch {
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined };
}
}
export default function ClientsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const {
clients, inbounds, onlines, loading, fetched, subSettings,
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, removeMany, attach, detach,
resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent, applyInvalidate,
} = useClients();
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
});
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
const [infoOpen, setInfoOpen] = useState(false);
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
const [qrOpen, setQrOpen] = useState(false);
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const initial = readFilterState();
const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
const [searchKey, setSearchKey] = useState(initial.searchKey);
const [filterBy, setFilterBy] = useState(initial.filterBy);
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [tablePageSize, setTablePageSize] = useState(20);
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter, searchKey, filterBy, protocolFilter,
}));
}, [enableFilter, searchKey, filterBy, protocolFilter]);
useEffect(() => {
if (pageSize > 0) {
/* eslint-disable-next-line react-hooks/set-state-in-effect */
setTablePageSize(pageSize);
}
}, [pageSize]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
const inboundsById = useMemo(() => {
const out: Record<number, InboundOption> = {};
for (const ib of inbounds) out[ib.id] = ib;
return out;
}, [inbounds]);
const protocolOptions = useMemo(() => {
const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
return [...values].sort();
}, [inbounds]);
const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
function inboundLabel(id: number) {
const ib = inboundsById[id];
if (!ib) return `#${id}`;
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
}
const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
if (!row) return null;
const traffic = row.traffic || {};
const used = (traffic.up || 0) + (traffic.down || 0);
const total = row.totalGB || 0;
const now = Date.now();
const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
const exhausted = total > 0 && used >= total;
if (expired || exhausted) return 'depleted';
if (!row.enable) return 'deactive';
const nearExpiry = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
const nearLimit = total > 0 && total - used < (trafficDiff || 0);
if (nearExpiry || nearLimit) return 'expiring';
return 'active';
}, [expireDiff, trafficDiff]);
function bucketBadgeColor(bucket: Bucket | null): string {
switch (bucket) {
case 'depleted': return '#ff4d4f';
case 'expiring': return '#fa8c16';
case 'deactive': return 'rgba(128,128,128,0.6)';
case 'active': return '#52c41a';
default: return 'rgba(128,128,128,0.6)';
}
}
function clientMatchesProtocol(row: ClientRecord, protocol?: string) {
if (!protocol) return true;
const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
for (const id of ids) {
const ib = inboundsById[id];
if (ib && ib.protocol === protocol) return true;
}
return false;
}
const filteredClients = useMemo(() => {
let rows = clients || [];
if (enableFilter) {
if (filterBy === 'online') {
rows = rows.filter((r) => r.enable && isOnline(r.email));
} else if (filterBy) {
rows = rows.filter((r) => clientBucket(r) === filterBy);
}
} else if (!ObjectUtil.isEmpty(searchKey)) {
rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey));
}
if (protocolFilter) {
rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter));
}
return rows;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clients, enableFilter, filterBy, searchKey, protocolFilter, clientBucket]);
const summary = useMemo(() => {
const rows = clients || [];
const deactive: string[] = [];
const depleted: string[] = [];
const expiring: string[] = [];
const online: string[] = [];
let active = 0;
for (const row of rows) {
const bucket = clientBucket(row);
if (bucket === 'deactive') deactive.push(row.email);
else if (bucket === 'depleted') depleted.push(row.email);
else if (bucket === 'expiring') expiring.push(row.email);
else if (bucket === 'active') active++;
if (row.enable && isOnline(row.email)) online.push(row.email);
}
return { total: rows.length, active, deactive, depleted, expiring, online };
}, [clients, clientBucket, isOnline]);
const sortFns: Record<string, (a: ClientRecord, b: ClientRecord) => number> = {
enable: (a, b) => Number(a.enable) - Number(b.enable),
email: (a, b) => (a.email || '').localeCompare(b.email || ''),
inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0),
traffic: (a, b) => {
const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0);
const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0);
return ua - ub;
},
remaining: (a, b) => {
const ra = (a.totalGB || 0) > 0 ? (a.totalGB || 0) - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity;
const rb = (b.totalGB || 0) > 0 ? (b.totalGB || 0) - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity;
return ra - rb;
},
expiryTime: (a, b) => {
const ea = (a.expiryTime ?? 0) > 0 ? (a.expiryTime ?? 0) : Infinity;
const eb = (b.expiryTime ?? 0) > 0 ? (b.expiryTime ?? 0) : Infinity;
return ea - eb;
},
};
const sortedClients = useMemo(() => {
if (!sortColumn || !sortOrder) return filteredClients;
const fn = sortFns[sortColumn];
if (!fn) return filteredClients;
const sorted = [...filteredClients].sort(fn);
return sortOrder === 'descend' ? sorted.reverse() : sorted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredClients, sortColumn, sortOrder]);
function trafficLabel(row: ClientRecord) {
const t0 = row.traffic;
if (!t0) return '-';
const used = (t0.up || 0) + (t0.down || 0);
const total = row.totalGB || 0;
if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
}
function remainingLabel(row: ClientRecord) {
const total = row.totalGB || 0;
if (total <= 0) return '∞';
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
const r = total - used;
return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
}
function remainingColor(row: ClientRecord): string {
const total = row.totalGB || 0;
if (total <= 0) return 'purple';
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
const ratio = used / total;
if (ratio >= 1) return 'red';
if (ratio >= 0.85) return 'orange';
return 'green';
}
function expiryLabel(row: ClientRecord) {
if (!row.expiryTime) return '∞';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${t('pages.clients.delayedStart')}: ${days}d`;
}
return IntlUtil.formatDate(row.expiryTime);
}
function expiryRelative(row: ClientRecord) {
if (!row.expiryTime) return '';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${days}d`;
}
return IntlUtil.formatRelativeTime(row.expiryTime);
}
function expiryColor(row: ClientRecord): string {
if (!row.expiryTime) return 'purple';
if (row.expiryTime < 0) return 'blue';
const now = Date.now();
if (row.expiryTime <= now) return 'red';
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
return 'green';
}
async function onToggleEnable(row: ClientRecord, next: boolean) {
setTogglingEmail(row.email);
try {
const msg = await setEnable(row, next);
if (!msg?.success) {
message.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
setTogglingEmail(null);
}
}
function onAdd() {
setFormMode('add');
setEditingClient(null);
setEditingAttachedIds([]);
setFormOpen(true);
}
function onEdit(row: ClientRecord) {
setFormMode('edit');
setEditingClient({ ...row });
setEditingAttachedIds(Array.isArray(row.inboundIds) ? [...row.inboundIds] : []);
setFormOpen(true);
}
function onDelete(row: ClientRecord) {
modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(row.email);
if (msg?.success) message.success(t('pages.clients.toasts.deleted'));
},
});
}
function onResetTraffic(row: ClientRecord) {
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
message.warning(t('pages.clients.resetNotPossible'));
return;
}
modal.confirm({
title: `${t('pages.inbounds.resetTraffic')}${row.email}`,
content: t('pages.inbounds.resetTrafficContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetTraffic(row);
if (msg?.success) message.success(t('pages.clients.toasts.trafficReset'));
},
});
}
function onShowInfo(row: ClientRecord) {
setInfoClient(row);
setInfoOpen(true);
}
function onShowQr(row: ClientRecord) {
setQrClient(row);
setQrOpen(true);
}
function onResetAllTraffics() {
modal.confirm({
title: t('pages.clients.resetAllTrafficsTitle'),
content: t('pages.clients.resetAllTrafficsContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetAllTraffics();
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
},
});
}
function onDelDepleted() {
modal.confirm({
title: t('pages.clients.delDepletedConfirmTitle'),
content: t('pages.clients.delDepletedConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await delDepleted();
if (msg?.success) {
const deleted = msg.obj?.deleted ?? 0;
message.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
}
},
});
}
function onBulkDelete() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
content: t('pages.clients.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const results = await removeMany(emails);
setSelectedRowKeys([]);
let ok = 0;
let failed = 0;
let firstError = '';
for (const msg of results) {
if (msg?.success) ok++;
else {
failed++;
if (!firstError && msg?.msg) firstError = msg.msg;
}
}
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
message.warning(firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
}
},
});
}
const onSave = useCallback(async (
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
) => {
if (!meta.isEdit) {
return create(payload);
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
const r = await attach(meta.email, meta.attach);
if (!r?.success) return r;
}
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
const r = await detach(meta.email, meta.detach);
if (!r?.success) return r;
}
return updateMsg;
}, [create, update, attach, detach]);
const pageClass = useMemo(() => {
const classes = ['clients-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setTablePageSize(pag.pageSize);
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
};
const columns = useMemo<ColumnsType<ClientRecord>>(() => {
function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
return {
...col,
sorter: true,
showSorterTooltip: false,
sortOrder: sortColumn === key ? sortOrder : null,
sortDirections: ['ascend', 'descend'],
};
}
return [
{
title: t('pages.clients.actions'),
key: 'actions',
width: 200,
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.moreInformation')}>
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
},
sortableCol({
title: t('pages.clients.enabled'), key: 'enable', width: 80,
render: (_v, record) => (
<Switch
checked={!!record.enable}
size="small"
loading={togglingEmail === record.email}
onChange={(next) => onToggleEnable(record, next)}
/>
),
}, 'enable'),
{
title: t('pages.clients.online'),
key: 'online',
width: 90,
render: (_v, record) => {
const bucket = clientBucket(record);
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
return <Tag>{t('pages.clients.offline')}</Tag>;
},
},
sortableCol({
title: t('pages.clients.client'),
key: 'email',
render: (_v, record) => (
<div className="email-cell">
<span className="email">{record.email}</span>
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
</div>
),
}, 'email'),
sortableCol({
title: t('pages.clients.attachedInbounds'),
key: 'inboundIds',
render: (_v, record) => {
const ids = record.inboundIds || [];
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>;
return ids.map((id) => (
<Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
));
},
}, 'inboundIds'),
sortableCol({
title: t('pages.clients.traffic'),
key: 'traffic',
render: (_v, record) => trafficLabel(record),
}, 'traffic'),
sortableCol({
title: t('pages.clients.remaining'),
key: 'remaining',
width: 130,
render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
}, 'remaining'),
sortableCol({
title: t('pages.clients.duration'),
key: 'expiryTime',
render: (_v, record) => (
<Tooltip title={expiryLabel(record)}>
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
</Tooltip>
),
}, 'expiryTime'),
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline]);
const tablePagination = {
current: currentPage,
pageSize: tablePageSize,
total: sortedClients.length,
showSizeChanger: sortedClients.length > 10,
pageSizeOptions: ['10', '20', '50', '100'],
hideOnSinglePage: sortedClients.length <= tablePageSize,
};
const rowSelection = {
selectedRowKeys,
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
};
function toggleSelect(email: string, checked: boolean) {
setSelectedRowKeys((prev) => {
const next = new Set(prev);
if (checked) next.add(email); else next.delete(email);
return Array.from(next);
});
}
function selectAll(checked: boolean) {
setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
}
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
function onToggleFilter(checked: boolean) {
setEnableFilter(checked);
if (checked) setSearchKey('');
else setFilterBy('');
}
return (
<ConfigProvider theme={antdThemeConfig}>
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} tip={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}>
<Col xs={12} sm={8} md={4}>
<CustomStatistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('online')}
open={summary.online.length ? undefined : false}
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
>
<CustomStatistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depleted')}
open={summary.depleted.length ? undefined : false}
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
>
<CustomStatistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depletingSoon')}
open={summary.expiring.length ? undefined : false}
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
>
<CustomStatistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('disabled')}
open={summary.deactive.length ? undefined : false}
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
>
<CustomStatistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<CustomStatistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
size="small"
title={
<div className="card-toolbar">
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
{!isMobile && t('pages.clients.addClients')}
</Button>
<Button size="small" icon={<UsergroupAddOutlined />} onClick={() => setBulkAddOpen(true)}>
{!isMobile && t('pages.clients.bulk')}
</Button>
{selectedRowKeys.length > 0 && (
<Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
{t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
</Button>
)}
<Button size="small" icon={<RetweetOutlined />} onClick={onResetAllTraffics}>
{!isMobile && t('pages.clients.resetAllTraffics')}
</Button>
<Button size="small" danger icon={<RestOutlined />} onClick={onDelDepleted}>
{!isMobile && t('pages.clients.delDepleted')}
</Button>
</div>
}
>
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
<Switch
checked={enableFilter}
onChange={onToggleFilter}
checkedChildren={<SearchOutlined />}
unCheckedChildren={<FilterOutlined />}
/>
{!enableFilter && (
<Input
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('search')}
autoFocus
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 300 }}
/>
)}
{enableFilter && (
<Radio.Group
value={filterBy}
onChange={(e) => setFilterBy(e.target.value)}
optionType="button"
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
>
<Radio.Button value="">{t('none')}</Radio.Button>
<Radio.Button value="active">{t('subscription.active')}</Radio.Button>
<Radio.Button value="deactive">{t('disabled')}</Radio.Button>
<Radio.Button value="depleted">{t('depleted')}</Radio.Button>
<Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
<Radio.Button value="online">{t('online')}</Radio.Button>
</Radio.Group>
)}
<Select
value={protocolFilter}
onChange={(v) => setProtocolFilter(v)}
allowClear
placeholder={t('pages.inbounds.protocol')}
size={isMobile ? 'small' : 'middle'}
style={{ width: 150 }}
options={protocolOptions.map((p) => ({ value: p, label: p }))}
/>
</div>
{!isMobile ? (
<Table<ClientRecord>
columns={columns}
dataSource={sortedClients}
loading={loading}
rowKey="email"
rowSelection={rowSelection}
pagination={tablePagination}
size="small"
onChange={onTableChange}
locale={{
emptyText: (
<div className="clients-empty">
<UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('pages.clients.empty')}</div>
</div>
),
}}
/>
) : (
<Spin spinning={loading}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => selectAll(e.target.checked)}
>
{t('pages.clients.selectAll')}
</Checkbox>
{selectedRowKeys.length > 0 && (
<span className="bulk-count">{selectedRowKeys.length}</span>
)}
</div>
)}
{filteredClients.length === 0 && (
<div className="card-empty">
<UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('pages.clients.empty')}</div>
</div>
)}
{filteredClients.map((row) => {
const bucket = clientBucket(row);
return (
<div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
<div className="card-head">
<Checkbox
checked={selectedRowKeys.includes(row.email)}
onChange={(e) => toggleSelect(row.email, e.target.checked)}
/>
<Badge color={bucketBadgeColor(bucket)} />
<span className="tag-name">{row.email}</span>
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('pages.clients.moreInformation')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
</Tooltip>
<Switch
checked={!!row.enable}
size="small"
loading={togglingEmail === row.email}
onChange={(next) => onToggleEnable(row, next)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'qr',
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
onClick: () => onShowQr(row),
},
{
key: 'reset',
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
onClick: () => onResetTraffic(row),
},
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(row),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(row),
},
],
}}
>
<MoreOutlined className="row-action-trigger" />
</Dropdown>
</div>
</div>
</div>
);
})}
</div>
</Spin>
)}
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<ClientFormModal
open={formOpen}
mode={formMode}
client={editingClient}
attachedIds={editingAttachedIds}
inbounds={inbounds}
ipLimitEnable={ipLimitEnable}
tgBotEnable={tgBotEnable}
save={onSave}
onOpenChange={setFormOpen}
/>
<ClientInfoModal
open={infoOpen}
client={infoClient}
inboundsById={inboundsById}
isOnline={infoClient ? isOnline(infoClient.email) : false}
subSettings={subSettings}
onOpenChange={setInfoOpen}
/>
<ClientQrModal
open={qrOpen}
client={qrClient}
subSettings={subSettings}
onOpenChange={setQrOpen}
/>
<ClientBulkAddModal
open={bulkAddOpen}
inbounds={inbounds}
ipLimitEnable={ipLimitEnable}
onOpenChange={setBulkAddOpen}
onSaved={() => setBulkAddOpen(false)}
/>
</Layout>
</ConfigProvider>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,220 +0,0 @@
import { onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
export function useClients() {
const clients = shallowRef([]);
const inbounds = shallowRef([]);
const onlines = ref([]);
const loading = ref(false);
const fetched = ref(false);
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
const ipLimitEnable = ref(false);
const tgBotEnable = ref(false);
const expireDiff = ref(0);
const trafficDiff = ref(0);
const pageSize = ref(0);
async function refresh() {
loading.value = true;
try {
const [clientsMsg, inboundsMsg] = await Promise.all([
HttpUtil.get('/panel/api/clients/list'),
HttpUtil.get('/panel/api/inbounds/options'),
]);
if (clientsMsg?.success) {
clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
}
if (inboundsMsg?.success) {
inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
}
fetched.value = true;
} finally {
loading.value = false;
}
}
async function fetchSubSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = msg.obj || {};
subSettings.value = {
enable: !!s.subEnable,
subURI: s.subURI || '',
subJsonURI: s.subJsonURI || '',
subJsonEnable: !!s.subJsonEnable,
};
ipLimitEnable.value = !!s.ipLimitEnable;
tgBotEnable.value = !!s.tgBotEnable;
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
pageSize.value = s.pageSize ?? 0;
}
async function create(payload) {
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function update(email, client) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function remove(email, keepTraffic = false) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const url = keepTraffic
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
: `/panel/api/clients/del/${encoded}`;
const msg = await HttpUtil.post(url);
if (msg?.success) await refresh();
return msg;
}
async function removeMany(emails, keepTraffic = false) {
if (!Array.isArray(emails) || emails.length === 0) return [];
const suffix = keepTraffic ? '?keepTraffic=1' : '';
const silentOpts = { silent: true };
const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, silentOpts);
}));
await refresh();
return results;
}
async function attach(email, inboundIds) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function detach(email, inboundIds) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function resetTraffic(client) {
if (!client?.email) return null;
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
const msg = await HttpUtil.post(url);
if (msg?.success) await refresh();
return msg;
}
async function resetAllTraffics() {
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics');
if (msg?.success) await refresh();
return msg;
}
async function delDepleted() {
const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
if (msg?.success) await refresh();
return msg;
}
async function setEnable(client, enable) {
if (!client?.email) return null;
const payload = {
email: client.email,
subId: client.subId,
id: client.uuid,
password: client.password,
auth: client.auth,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
limitIp: client.limitIp || 0,
comment: client.comment || '',
enable: !!enable,
};
return update(client.email, payload);
}
function applyTrafficEvent(payload) {
if (!payload || typeof payload !== 'object') return;
if (Array.isArray(payload.onlineClients)) {
onlines.value = payload.onlineClients;
}
}
function applyClientStatsEvent(payload) {
if (!payload || typeof payload !== 'object') return;
if (!Array.isArray(payload.clients) || payload.clients.length === 0) return;
const byEmail = new Map();
for (const row of payload.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
let touched = false;
const next = clients.value || [];
for (let i = 0; i < next.length; i++) {
const row = next[i];
const upd = byEmail.get(row?.email);
if (!upd) continue;
const merged = { ...(row.traffic || {}) };
if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
next[i] = { ...row, traffic: merged };
touched = true;
}
if (touched) clients.value = [...next];
}
let invalidateTimer = null;
function applyInvalidate(payload) {
if (!payload || typeof payload !== 'object') return;
if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => {
invalidateTimer = null;
refresh();
}, 200);
}
onMounted(async () => {
await Promise.all([refresh(), fetchSubSettings()]);
});
return {
clients,
inbounds,
onlines,
loading,
fetched,
subSettings,
ipLimitEnable,
tgBotEnable,
expireDiff,
trafficDiff,
pageSize,
refresh,
create,
update,
remove,
removeMany,
attach,
detach,
resetTraffic,
resetAllTraffics,
delDepleted,
setEnable,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
};
}

View file

@ -0,0 +1,40 @@
.qr-panel {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.qr-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.qr-remark {
margin: 0;
}
.qr-panel-canvas {
display: flex;
justify-content: center;
padding: 6px 0;
}
.qr-panel-canvas .qr-code {
cursor: pointer;
background: #fff;
border-radius: 4px;
line-height: 0;
}
.qr-panel-canvas .qr-code svg {
display: block;
width: 100%;
height: auto;
max-width: 360px;
}

View file

@ -0,0 +1,128 @@
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, QRCode, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
import './QrPanel.css';
interface QrPanelProps {
value: string;
remark?: string;
downloadName?: string;
size?: number;
showQr?: boolean;
}
async function svgToPngBlob(svgEl: SVGSVGElement | null, size: number): Promise<Blob | null> {
if (!svgEl) return null;
const svgData = new XMLSerializer().serializeToString(svgEl);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
return new Promise<Blob | null>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
resolve(null);
return;
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => resolve(blob), 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
function downloadImageBlob(blob: Blob, remark: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${remark || 'qrcode'}.png`;
link.click();
URL.revokeObjectURL(url);
}
export default function QrPanel({
value,
remark = '',
downloadName = '',
size = 360,
showQr = true,
}: QrPanelProps) {
const { t } = useTranslation();
const qrRef = useRef<HTMLDivElement | null>(null);
async function copy() {
const ok = await ClipboardManager.copyText(value);
if (ok) message.success(t('copied'));
}
function download() {
if (!downloadName) return;
FileManager.downloadTextFile(value, downloadName);
}
async function copyImage() {
const svgEl = qrRef.current?.querySelector('svg') as SVGSVGElement | null;
const blob = await svgToPngBlob(svgEl, size);
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
message.success(t('copied'));
} catch {
downloadImageBlob(blob, remark);
}
}
async function downloadImage() {
const svgEl = qrRef.current?.querySelector('svg') as SVGSVGElement | null;
const blob = await svgToPngBlob(svgEl, size);
if (blob) downloadImageBlob(blob, remark);
}
return (
<div className="qr-panel">
<div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
</Tooltip>
{showQr && (
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}>
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} />
</Tooltip>
)}
{downloadName && (
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={download} />
</Tooltip>
)}
</div>
{showQr && (
<div ref={qrRef} className="qr-panel-canvas">
<Tooltip title={t('copy')}>
<QRCode
className="qr-code"
value={value}
size={size}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
onClick={copyImage}
/>
</Tooltip>
</div>
)}
</div>
);
}