mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
d50ec74b24
commit
ef36757b88
25 changed files with 3101 additions and 2517 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
39
frontend/src/components/DateTimePicker.tsx
Normal file
39
frontend/src/components/DateTimePicker.tsx
Normal 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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
28
frontend/src/entries/clients.tsx
Normal file
28
frontend/src/entries/clients.tsx
Normal 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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
282
frontend/src/hooks/useClients.ts
Normal file
282
frontend/src/hooks/useClients.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
frontend/src/hooks/useDatepicker.ts
Normal file
57
frontend/src/hooks/useDatepicker.ts
Normal 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 };
|
||||
}
|
||||
9
frontend/src/models/inbound.ts
Normal file
9
frontend/src/models/inbound.ts
Normal 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;
|
||||
5
frontend/src/pages/clients/ClientBulkAddModal.css
Normal file
5
frontend/src/pages/clients/ClientBulkAddModal.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
337
frontend/src/pages/clients/ClientBulkAddModal.tsx
Normal file
337
frontend/src/pages/clients/ClientBulkAddModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
1
frontend/src/pages/clients/ClientFormModal.css
Normal file
1
frontend/src/pages/clients/ClientFormModal.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Client form modal — additional layout overrides if needed. */
|
||||
522
frontend/src/pages/clients/ClientFormModal.tsx
Normal file
522
frontend/src/pages/clients/ClientFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
98
frontend/src/pages/clients/ClientInfoModal.css
Normal file
98
frontend/src/pages/clients/ClientInfoModal.css
Normal 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);
|
||||
}
|
||||
294
frontend/src/pages/clients/ClientInfoModal.tsx
Normal file
294
frontend/src/pages/clients/ClientInfoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
128
frontend/src/pages/clients/ClientQrModal.tsx
Normal file
128
frontend/src/pages/clients/ClientQrModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import QrPanel from '@/pages/inbounds/QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
client: { type: Object, default: null },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const subLink = computed(() => {
|
||||
if (!props.client?.subId || !props.subSettings?.enable || !props.subSettings?.subURI) return '';
|
||||
return props.subSettings.subURI + props.client.subId;
|
||||
});
|
||||
|
||||
const subJsonLink = computed(() => {
|
||||
if (!props.client?.subId || !props.subSettings?.enable) return '';
|
||||
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
|
||||
return props.subSettings.subJsonURI + props.client.subId;
|
||||
});
|
||||
|
||||
const activeKeys = computed(() => {
|
||||
const keys = [];
|
||||
if (subLink.value) keys.push('sub');
|
||||
if (subJsonLink.value) keys.push('subJson');
|
||||
if (links.value.length > 0) keys.push('l0');
|
||||
return keys;
|
||||
});
|
||||
|
||||
const hasAnything = computed(
|
||||
() => !!subLink.value || !!subJsonLink.value || links.value.length > 0,
|
||||
);
|
||||
|
||||
watch(() => props.open, async (next) => {
|
||||
if (!next || !props.client?.subId) {
|
||||
links.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
|
||||
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
|
||||
@cancel="close">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!client?.subId && !loading" class="empty">
|
||||
{{ t('pages.clients.noSubId') }}
|
||||
</div>
|
||||
<div v-else-if="!hasAnything && !loading" class="empty">
|
||||
{{ t('pages.clients.noLinks') }}
|
||||
</div>
|
||||
<a-collapse v-else :active-key="activeKeys" accordion>
|
||||
<a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
|
||||
<QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
|
||||
<QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
|
||||
:header="`${t('pages.clients.link')} ${idx + 1}`">
|
||||
<QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
221
frontend/src/pages/clients/ClientsPage.css
Normal file
221
frontend/src/pages/clients/ClientsPage.css
Normal 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;
|
||||
}
|
||||
899
frontend/src/pages/clients/ClientsPage.tsx
Normal file
899
frontend/src/pages/clients/ClientsPage.tsx
Normal 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
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
40
frontend/src/pages/inbounds/QrPanel.css
Normal file
40
frontend/src/pages/inbounds/QrPanel.css
Normal 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;
|
||||
}
|
||||
128
frontend/src/pages/inbounds/QrPanel.tsx
Normal file
128
frontend/src/pages/inbounds/QrPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue