| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644 |
- import { useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import {
- AutoComplete,
- Button,
- Col,
- Form,
- Input,
- InputNumber,
- Modal,
- Row,
- Select,
- Space,
- Switch,
- Tag,
- message,
- } from 'antd';
- import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
- import dayjs from 'dayjs';
- import type { Dayjs } from 'dayjs';
- import { HttpUtil, RandomUtil } from '@/utils';
- import DateTimePicker from '@/components/DateTimePicker';
- import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
- import type { ClientRecord, InboundOption } from '@/hooks/useClients';
- import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
- const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
- const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
- const MULTI_CLIENT_PROTOCOLS = new Set([
- 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
- ]);
- 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;
- groups?: string[];
- 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;
- security: string;
- reverseTag: string;
- totalGB: number;
- expiryDate: Dayjs | null;
- delayedStart: boolean;
- delayedDays: number;
- reset: number;
- limitIp: number;
- tgId: number;
- group: string;
- comment: string;
- enable: boolean;
- inboundIds: number[];
- }
- function emptyForm(): FormState {
- return {
- email: '',
- subId: '',
- uuid: '',
- password: '',
- auth: '',
- flow: '',
- security: 'auto',
- reverseTag: '',
- totalGB: 0,
- expiryDate: null,
- delayedStart: false,
- delayedDays: 0,
- reset: 0,
- limitIp: 0,
- tgId: 0,
- group: '',
- 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,
- groups = [],
- save,
- onOpenChange,
- }: ClientFormModalProps) {
- const { t } = useTranslation();
- const [messageApi, messageContextHolder] = message.useMessage();
- const isEdit = mode === 'edit';
- const [form, setForm] = useState<FormState>(emptyForm);
- const [submitting, setSubmitting] = useState(false);
- const [clientIps, setClientIps] = useState<string[]>([]);
- const [ipsLoading, setIpsLoading] = useState(false);
- const [ipsClearing, setIpsClearing] = useState(false);
- const [ipsModalOpen, setIpsModalOpen] = useState(false);
- function update<K extends keyof FormState>(key: K, value: FormState[K]) {
- setForm((prev) => ({ ...prev, [key]: value }));
- }
- useEffect(() => {
- if (!open) return;
- setIpsModalOpen(false);
- 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 || '',
- security: client.security || 'auto',
- reverseTag: client.reverse?.tag || '',
- totalGB: bytesToGB(client.totalGB || 0),
- reset: Number(client.reset) || 0,
- limitIp: client.limitIp || 0,
- tgId: Number(client.tgId) || 0,
- group: client.group || '',
- 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(10),
- uuid: RandomUtil.randomUUID(),
- subId: RandomUtil.randomLowerAndNum(16),
- password: RandomUtil.randomLowerAndNum(16),
- auth: RandomUtil.randomLowerAndNum(16),
- });
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, isEdit]);
- const flowCapableIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row?.tlsFlowCapable) ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const vlessLikeIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'vless') ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const vmessIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'vmess') 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],
- );
- const showSecurity = useMemo(
- () => (form.inboundIds || []).some((id) => vmessIds.has(id)),
- [form.inboundIds, vmessIds],
- );
- useEffect(() => {
- if (!showFlow && form.flow) {
- update('flow', '');
- }
- }, [showFlow, form.flow]);
- useEffect(() => {
- if (!showReverseTag && form.reverseTag) {
- update('reverseTag', '');
- }
- }, [showReverseTag, form.reverseTag]);
- const inboundOptions = useMemo(
- () => (inbounds || [])
- .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
- .map((ib) => ({
- label: ib.tag ?? '',
- value: ib.id,
- title: ib.tag ?? '',
- })),
- [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);
- }
- }
- function openIpsModal() {
- setIpsModalOpen(true);
- if (clientIps.length === 0) void loadIps();
- }
- 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() {
- const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
- const validated = schema.safeParse({
- email: form.email,
- subId: form.subId,
- uuid: form.uuid,
- password: form.password,
- auth: form.auth,
- flow: form.flow,
- security: form.security,
- reverseTag: form.reverseTag,
- totalGB: form.totalGB,
- delayedStart: form.delayedStart,
- delayedDays: form.delayedDays,
- reset: form.reset,
- limitIp: form.limitIp,
- tgId: form.tgId,
- group: form.group,
- comment: form.comment,
- enable: form.enable,
- inboundIds: form.inboundIds,
- });
- if (!validated.success) {
- const issue = validated.error.issues[0];
- messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
- 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 || '') : '',
- security: showSecurity ? (form.security || 'auto') : 'auto',
- totalGB: gbToBytes(form.totalGB),
- expiryTime,
- reset: Number(form.reset) || 0,
- limitIp: Number(form.limitIp) || 0,
- tgId: Number(form.tgId) || 0,
- comment: form.comment,
- enable: !!form.enable,
- };
- const reverseTag = showReverseTag ? (form.reverseTag || '').trim() : '';
- if (reverseTag) {
- clientPayload.reverse = { tag: reverseTag };
- }
- setSubmitting(true);
- try {
- let msg;
- if (isEdit && client) {
- const original = new Set(attachedIds || []);
- const next = new Set(form.inboundIds || []);
- const toAttach = [...next].filter((id) => !original.has(id));
- const toDetach = [...original].filter((id) => !next.has(id));
- msg = await save(clientPayload, {
- isEdit: true,
- email: client.email,
- attach: toAttach,
- detach: toDetach,
- });
- } else {
- msg = await save(
- { client: clientPayload, inboundIds: form.inboundIds },
- { isEdit: false },
- );
- }
- if (msg?.success) close();
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <>
- {messageContextHolder}
- <Modal
- open={open}
- title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
- destroyOnHidden
- okText={isEdit ? t('save') : t('create')}
- cancelText={t('cancel')}
- okButtonProps={{ loading: submitting }}
- width={720}
- onOk={onSubmit}
- onCancel={close}
- >
- <Form layout="vertical">
- <Row gutter={16}>
- <Col xs={24} md={12}>
- <Form.Item label={t('pages.clients.email')} required>
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={form.email}
- placeholder={t('pages.clients.email')}
- style={{ flex: 1 }}
- onChange={(e) => update('email', e.target.value)}
- />
- <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
- </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 icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
- </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 icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
- </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 icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} />
- </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 icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
- </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={1} style={{ width: '100%' }}
- onChange={(v) => update('totalGB', Number(v) || 0)} />
- </Form.Item>
- </Col>
- {ipLimitEnable && (
- <Col xs={24} md={4}>
- <Form.Item label={t('pages.clients.limitIp')}>
- <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
- onChange={(v) => update('limitIp', Number(v) || 0)} />
- </Form.Item>
- </Col>
- )}
- </Row>
- <Row gutter={16}>
- <Col xs={24} md={12}>
- {form.delayedStart ? (
- <Form.Item label={t('pages.clients.expireDays')}>
- <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
- onChange={(v) => update('delayedDays', Number(v) || 0)} />
- </Form.Item>
- ) : (
- <Form.Item label={t('pages.clients.expiryTime')}>
- <DateTimePicker
- value={form.expiryDate}
- onChange={(d) => update('expiryDate', d || null)}
- />
- </Form.Item>
- )}
- </Col>
- <Col xs={24} md={12}>
- <Form.Item label={t('pages.clients.delayedStart')}>
- <Switch
- checked={form.delayedStart}
- onChange={(v) => {
- update('delayedStart', v);
- if (v) update('expiryDate', null);
- else update('delayedDays', 0);
- }}
- />
- </Form.Item>
- </Col>
- </Row>
- <Row gutter={16}>
- <Col xs={24} md={12}>
- <Form.Item
- label={t('pages.clients.renew')}
- tooltip={t('pages.clients.renewDesc')}
- >
- <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
- onChange={(v) => update('reset', Number(v) || 0)} />
- </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>
- )}
- {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>
- )}
- {showSecurity && (
- <Col xs={24} md={12}>
- <Form.Item label={t('pages.clients.vmessSecurity')}>
- <Select
- value={form.security}
- onChange={(v) => update('security', v)}
- options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
- />
- </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>
- <Col xs={24} md={12}>
- <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
- <AutoComplete
- value={form.group}
- placeholder={t('pages.clients.groupPlaceholder')}
- options={groups.map((g) => ({ value: g }))}
- onChange={(v) => update('group', v ?? '')}
- filterOption={(input, option) =>
- String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
- }
- allowClear
- style={{ width: '100%' }}
- />
- </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}
- placeholder={t('pages.clients.selectInbound')}
- maxTagCount="responsive"
- placement="topLeft"
- listHeight={220}
- showSearch={{
- 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')}>
- <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
- {clientIps.length > 0 ? clientIps.length : ''}
- </Button>
- </Form.Item>
- )}
- </Form>
- </Modal>
- <Modal
- open={ipsModalOpen}
- title={`${t('pages.clients.ipLog')}${client?.email ? ` — ${client.email}` : ''}`}
- width={440}
- onCancel={() => setIpsModalOpen(false)}
- footer={[
- <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
- {t('refresh')}
- </Button>,
- <Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
- {t('pages.clients.clearAll')}
- </Button>,
- <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
- {t('close')}
- </Button>,
- ]}
- >
- {clientIps.length > 0 ? (
- <div style={{ maxHeight: 360, overflowY: 'auto' }}>
- {clientIps.map((ip, idx) => (
- <Tag
- key={idx}
- color="blue"
- style={{
- display: 'block',
- width: 'fit-content',
- maxWidth: '100%',
- marginBottom: 6,
- padding: '2px 8px',
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
- }}
- >
- {ip}
- </Tag>
- ))}
- </div>
- ) : (
- <Tag>{t('tgbot.noIpRecord')}</Tag>
- )}
- </Modal>
- </>
- );
- }
|