Просмотр исходного кода

feat(inbounds): attach existing clients to an inbound in one click

Adds an 'Attach Existing Clients' row action on multi-user inbounds (shown even when the inbound is empty). It opens a modal listing the whole client pool with search and group filter, all attachable clients pre-selected, and attaches the selection to that inbound via the existing bulkAttach endpoint. Clients already on the inbound are shown disabled and skipped. Translations added for all 13 locales.
MHSanaei 9 часов назад
Родитель
Сommit
dd14e9b3b0

+ 16 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -39,6 +39,7 @@ const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
 const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
 const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
 const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
+const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
 const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
 const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
 
@@ -53,6 +54,7 @@ type RowAction =
   | 'resetTraffic'
   | 'delAllClients'
   | 'attachClients'
+  | 'attachExisting'
   | 'detachClients'
   | 'addToGroup'
   | 'clone';
@@ -129,6 +131,8 @@ export default function InboundsPage() {
 
   const [attachOpen, setAttachOpen] = useState(false);
   const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
+  const [attachExistingOpen, setAttachExistingOpen] = useState(false);
+  const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
   const [detachOpen, setDetachOpen] = useState(false);
   const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
 
@@ -523,6 +527,10 @@ export default function InboundsPage() {
         setAttachSource(target);
         setAttachOpen(true);
         break;
+      case 'attachExisting':
+        setAttachExistingTarget(target);
+        setAttachExistingOpen(true);
+        break;
       case 'detachClients':
         setDetachSource(target);
         setDetachOpen(true);
@@ -653,6 +661,14 @@ export default function InboundsPage() {
             dbInbounds={dbInbounds}
           />
         </LazyMount>
+        <LazyMount when={attachExistingOpen}>
+          <AttachExistingClientsModal
+            open={attachExistingOpen}
+            onClose={() => setAttachExistingOpen(false)}
+            onAttached={refresh}
+            target={attachExistingTarget}
+          />
+        </LazyMount>
         <LazyMount when={detachOpen}>
           <DetachClientsModal
             open={detachOpen}

+ 233 - 0
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -0,0 +1,233 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Input, Modal, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import { HttpUtil } from '@/utils';
+import type { DBInbound } from '@/models/dbinbound';
+
+interface AttachExistingClientsModalProps {
+  open: boolean;
+  target: DBInbound | null;
+  onClose: () => void;
+  onAttached?: () => void;
+}
+
+interface BulkAttachResult {
+  attached?: string[];
+  skipped?: string[];
+  errors?: string[];
+}
+
+interface ClientRow {
+  email: string;
+  group: string;
+  enable: boolean;
+  alreadyAttached: boolean;
+}
+
+interface RawClient {
+  email?: string;
+  group?: string;
+  enable?: boolean;
+  inboundIds?: number[] | null;
+}
+
+export default function AttachExistingClientsModal({
+  open,
+  target,
+  onClose,
+  onAttached,
+}: AttachExistingClientsModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [clientRows, setClientRows] = useState<ClientRow[]>([]);
+  const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
+  const [search, setSearch] = useState('');
+  const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined);
+
+  useEffect(() => {
+    if (!open || !target) return;
+    let cancelled = false;
+    setLoading(true);
+    setSearch('');
+    setGroupFilter(undefined);
+    HttpUtil.get('/panel/api/clients/list', undefined, { silent: true })
+      .then((msg) => {
+        if (cancelled) return;
+        const list = Array.isArray(msg?.obj) ? (msg.obj as RawClient[]) : [];
+        const rows: ClientRow[] = list
+          .map((c) => ({
+            email: (c?.email || '').trim(),
+            group: (c?.group || '').trim(),
+            enable: c?.enable !== false,
+            alreadyAttached: Array.isArray(c?.inboundIds) && c.inboundIds.includes(target.id),
+          }))
+          .filter((r) => r.email);
+        setClientRows(rows);
+        setSelectedEmails(rows.filter((r) => !r.alreadyAttached).map((r) => r.email));
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false);
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [open, target]);
+
+  const groupOptions = useMemo(() => {
+    const set = new Set<string>();
+    for (const r of clientRows) if (r.group) set.add(r.group);
+    return [...set].sort((a, b) => a.localeCompare(b)).map((g) => ({ value: g, label: g }));
+  }, [clientRows]);
+
+  const attachableCount = useMemo(
+    () => clientRows.filter((r) => !r.alreadyAttached).length,
+    [clientRows],
+  );
+
+  const filteredRows = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    return clientRows.filter((r) => {
+      if (groupFilter && r.group !== groupFilter) return false;
+      if (!q) return true;
+      return r.email.toLowerCase().includes(q) || r.group.toLowerCase().includes(q);
+    });
+  }, [clientRows, search, groupFilter]);
+
+  const columns: ColumnsType<ClientRow> = useMemo(
+    () => [
+      {
+        title: t('pages.inbounds.email'),
+        dataIndex: 'email',
+        key: 'email',
+        ellipsis: true,
+      },
+      {
+        title: t('pages.clients.group'),
+        dataIndex: 'group',
+        key: 'group',
+        width: 150,
+        ellipsis: true,
+        render: (group: string) =>
+          group ? <Tag color="geekblue">{group}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>,
+      },
+      {
+        title: t('enable'),
+        key: 'status',
+        width: 140,
+        render: (_v, row) => {
+          if (row.alreadyAttached) return <Tag color="default">{t('pages.inbounds.attachExistingStatusAttached')}</Tag>;
+          return row.enable ? (
+            <Tag color="success">{t('enable')}</Tag>
+          ) : (
+            <Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
+          );
+        },
+      },
+    ],
+    [t],
+  );
+
+  async function submit() {
+    if (!target || selectedEmails.length === 0) return;
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.post(
+        '/panel/api/clients/bulkAttach',
+        { emails: selectedEmails, inboundIds: [target.id] },
+        { headers: { 'Content-Type': 'application/json' } },
+      );
+      if (!msg?.success) {
+        messageApi.error(msg?.msg || t('somethingWentWrong'));
+        return;
+      }
+      const result = (msg.obj || {}) as BulkAttachResult;
+      const attached = result.attached?.length ?? 0;
+      const skipped = result.skipped?.length ?? 0;
+      const errors = result.errors?.length ?? 0;
+      if (errors > 0) {
+        messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
+      } else {
+        messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
+      }
+      onAttached?.();
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  const noClients = !loading && clientRows.length === 0;
+
+  return (
+    <Modal
+      open={open}
+      onCancel={onClose}
+      onOk={submit}
+      okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
+      okText={t('pages.inbounds.attachClients')}
+      cancelText={t('cancel')}
+      title={t('pages.inbounds.attachExistingTitle', { remark: target?.tag ?? '' })}
+      width={680}
+    >
+      {messageContextHolder}
+      <Typography.Paragraph type="secondary">
+        {t('pages.inbounds.attachExistingDesc', { count: attachableCount })}
+      </Typography.Paragraph>
+
+      {noClients ? (
+        <Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
+      ) : (
+        <Spin spinning={loading}>
+          <Space direction="vertical" size="small" style={{ width: '100%' }}>
+            <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
+              <Space wrap>
+                <Input.Search
+                  allowClear
+                  value={search}
+                  onChange={(e) => setSearch(e.target.value)}
+                  placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
+                  style={{ width: 260 }}
+                />
+                {groupOptions.length > 0 && (
+                  <Select
+                    allowClear
+                    value={groupFilter}
+                    onChange={(v) => setGroupFilter(v)}
+                    options={groupOptions}
+                    placeholder={t('pages.clients.group')}
+                    style={{ minWidth: 160 }}
+                    optionFilterProp="label"
+                  />
+                )}
+              </Space>
+              <Typography.Text type="secondary">
+                {t('pages.inbounds.attachClientsSelectedCount', {
+                  selected: selectedEmails.length,
+                  total: attachableCount,
+                })}
+              </Typography.Text>
+            </Space>
+            <Table<ClientRow>
+              size="small"
+              rowKey="email"
+              columns={columns}
+              dataSource={filteredRows}
+              pagination={false}
+              scroll={{ y: 280 }}
+              rowSelection={{
+                selectedRowKeys: selectedEmails,
+                onChange: (keys) => setSelectedEmails(keys as string[]),
+                getCheckboxProps: (row) => ({ disabled: row.alreadyAttached }),
+                preserveSelectedRowKeys: true,
+              }}
+            />
+          </Space>
+        </Spin>
+      )}
+    </Modal>
+  );
+}

+ 1 - 0
frontend/src/pages/inbounds/clients/index.ts

@@ -1,3 +1,4 @@
 export { default as AttachClientsModal } from './AttachClientsModal';
+export { default as AttachExistingClientsModal } from './AttachExistingClientsModal';
 export { default as DetachClientsModal } from './DetachClientsModal';
 export { default as AddClientsToGroupModal } from './AddClientsToGroupModal';

+ 3 - 0
frontend/src/pages/inbounds/list/RowActions.tsx

@@ -49,6 +49,9 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
   items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
   items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
   items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
+  if (isInboundMultiUser(record)) {
+    items.push({ key: 'attachExisting', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachExistingClients') });
+  }
   if (isInboundMultiUser(record) && hasClients) {
     items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
     items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });

+ 5 - 0
web/translation/ar-EG.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "ابحث بالبريد أو التعليق",
       "attachClientsStatusDisabled": "معطل",
       "attachClientsSelectedCount": "{selected} من {total} محدد",
+      "attachExistingClients": "إرفاق العملاء الحاليين…",
+      "attachExistingTitle": "إرفاق العملاء الحاليين بـ «{remark}»",
+      "attachExistingDesc": "يرفق العملاء الحاليين ({count} متاح) بهذا الوارد — بنفس UUID/كلمة المرور وحركة المرور المشتركة. يتم تخطي العملاء الموجودين عليه بالفعل.",
+      "attachExistingNoClients": "لا يوجد عملاء بعد. أنشئ عملاء أولاً ثم أرفقهم هنا.",
+      "attachExistingStatusAttached": "مُرفق بالفعل",
       "detachClients": "فصل العملاء",
       "detachClientsTitle": "فصل عملاء من «{remark}»",
       "detachClientsDesc": "يزيل العميل (العملاء) المحدد من هذا الوارد فقط. تُحفظ سجلات العملاء (استخدم Delete للإزالة الكاملة). المصدر يحتوي على {count} عميل إجمالاً.",

+ 5 - 0
web/translation/en-US.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Search email or comment",
       "attachClientsStatusDisabled": "Disabled",
       "attachClientsSelectedCount": "{selected} of {total} selected",
+      "attachExistingClients": "Attach Existing Clients…",
+      "attachExistingTitle": "Attach existing clients to \"{remark}\"",
+      "attachExistingDesc": "Attaches existing clients ({count} available) to this inbound — same UUID/password and shared traffic. Clients already on it are skipped.",
+      "attachExistingNoClients": "No clients exist yet. Create clients first, then attach them here.",
+      "attachExistingStatusAttached": "Already attached",
       "detachClients": "Detach Clients",
       "detachClientsTitle": "Detach clients of \"{remark}\"",
       "detachClientsDesc": "Removes the selected client(s) from this inbound only. Client records themselves are kept (use Delete to remove fully). Source has {count} clients in total.",

+ 5 - 0
web/translation/es-ES.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Buscar email o comentario",
       "attachClientsStatusDisabled": "Deshabilitado",
       "attachClientsSelectedCount": "{selected} de {total} seleccionado(s)",
+      "attachExistingClients": "Asociar clientes existentes…",
+      "attachExistingTitle": "Asociar clientes existentes a «{remark}»",
+      "attachExistingDesc": "Asocia los clientes existentes ({count} disponibles) a esta entrada: mismo UUID/contraseña y tráfico compartido. Los clientes que ya están en ella se omiten.",
+      "attachExistingNoClients": "Aún no hay clientes. Cree clientes primero y luego asócielos aquí.",
+      "attachExistingStatusAttached": "Ya asociado",
       "detachClients": "Desasociar clientes",
       "detachClientsTitle": "Desasociar clientes de «{remark}»",
       "detachClientsDesc": "Quita el cliente o clientes seleccionados solo de esta entrada. Los registros se conservan (usa Delete para eliminar por completo). El origen tiene {count} cliente(s) en total.",

+ 5 - 0
web/translation/fa-IR.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "جستجوی ایمیل یا توضیح",
       "attachClientsStatusDisabled": "غیرفعال",
       "attachClientsSelectedCount": "{selected} از {total} انتخاب‌شده",
+      "attachExistingClients": "الصاق کاربران موجود…",
+      "attachExistingTitle": "الصاق کاربران موجود به «{remark}»",
+      "attachExistingDesc": "کاربران موجود ({count} کاربر در دسترس) را به این ورودی الصاق می‌کند — با همان UUID/رمز و ترافیک مشترک. کاربرانی که از قبل روی این ورودی هستند نادیده گرفته می‌شوند.",
+      "attachExistingNoClients": "هنوز هیچ کاربری وجود ندارد. ابتدا کاربر بسازید، سپس اینجا الصاق کنید.",
+      "attachExistingStatusAttached": "از قبل الصاق‌شده",
       "detachClients": "جداسازی کاربران",
       "detachClientsTitle": "جداسازی کاربران از «{remark}»",
       "detachClientsDesc": "کاربر(های) انتخابی را تنها از این ورودی حذف می‌کند. خود رکورد کاربر حفظ می‌شود (برای حذف کامل از Delete استفاده کنید). مبدا در مجموع {count} کاربر دارد.",

+ 5 - 0
web/translation/id-ID.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Cari email atau komentar",
       "attachClientsStatusDisabled": "Dinonaktifkan",
       "attachClientsSelectedCount": "{selected} dari {total} dipilih",
+      "attachExistingClients": "Lampirkan klien yang ada…",
+      "attachExistingTitle": "Lampirkan klien yang ada ke «{remark}»",
+      "attachExistingDesc": "Melampirkan klien yang ada ({count} tersedia) ke inbound ini — UUID/kata sandi sama dan trafik bersama. Klien yang sudah ada di sini dilewati.",
+      "attachExistingNoClients": "Belum ada klien. Buat klien dulu, lalu lampirkan di sini.",
+      "attachExistingStatusAttached": "Sudah dilampirkan",
       "detachClients": "Lepas klien",
       "detachClientsTitle": "Lepas klien dari «{remark}»",
       "detachClientsDesc": "Menghapus klien terpilih hanya dari inbound ini. Catatan klien tetap dipertahankan (gunakan Delete untuk menghapus sepenuhnya). Sumber memiliki total {count} klien.",

+ 5 - 0
web/translation/ja-JP.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "メールまたはコメントを検索",
       "attachClientsStatusDisabled": "無効",
       "attachClientsSelectedCount": "{total} 中 {selected} 選択中",
+      "attachExistingClients": "既存のクライアントをアタッチ…",
+      "attachExistingTitle": "「{remark}」に既存のクライアントをアタッチ",
+      "attachExistingDesc": "既存のクライアント({count} 件)をこのインバウンドにアタッチします — 同じ UUID/パスワードと共有トラフィック。すでにアタッチ済みのクライアントはスキップされます。",
+      "attachExistingNoClients": "クライアントがまだありません。先にクライアントを作成してから、ここでアタッチしてください。",
+      "attachExistingStatusAttached": "アタッチ済み",
       "detachClients": "クライアントをデタッチ",
       "detachClientsTitle": "「{remark}」のクライアントをデタッチ",
       "detachClientsDesc": "選択したクライアントをこのインバウンドのみから外します。クライアントレコードは保持されます (完全に削除するには Delete を使用)。ソースには合計 {count} クライアントがあります。",

+ 5 - 0
web/translation/pt-BR.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Buscar email ou comentário",
       "attachClientsStatusDisabled": "Desabilitado",
       "attachClientsSelectedCount": "{selected} de {total} selecionado(s)",
+      "attachExistingClients": "Associar clientes existentes…",
+      "attachExistingTitle": "Associar clientes existentes a «{remark}»",
+      "attachExistingDesc": "Associa os clientes existentes ({count} disponíveis) a esta entrada — mesmo UUID/senha e tráfego compartilhado. Clientes que já estão nela são ignorados.",
+      "attachExistingNoClients": "Ainda não há clientes. Crie clientes primeiro e depois associe-os aqui.",
+      "attachExistingStatusAttached": "Já associado",
       "detachClients": "Desassociar clientes",
       "detachClientsTitle": "Desassociar clientes de «{remark}»",
       "detachClientsDesc": "Remove o(s) cliente(s) selecionado(s) apenas desta entrada. Os registros são mantidos (use Delete para remover completamente). A origem tem {count} cliente(s) no total.",

+ 5 - 0
web/translation/ru-RU.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Поиск email или комментария",
       "attachClientsStatusDisabled": "Отключено",
       "attachClientsSelectedCount": "{selected} из {total} выбрано",
+      "attachExistingClients": "Привязать существующих клиентов…",
+      "attachExistingTitle": "Привязать существующих клиентов к «{remark}»",
+      "attachExistingDesc": "Привязывает существующих клиентов (доступно {count}) к этому входящему — тот же UUID/пароль и общий трафик. Клиенты, уже привязанные к нему, пропускаются.",
+      "attachExistingNoClients": "Клиентов пока нет. Сначала создайте клиентов, затем привяжите их здесь.",
+      "attachExistingStatusAttached": "Уже привязан",
       "detachClients": "Отвязать клиентов",
       "detachClientsTitle": "Отвязать клиентов из «{remark}»",
       "detachClientsDesc": "Удаляет выбранных клиент(ов) только с этого входящего. Записи клиентов сохраняются (используйте Delete для полного удаления). У источника всего {count} клиент(ов).",

+ 5 - 0
web/translation/tr-TR.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Email veya yorum ara",
       "attachClientsStatusDisabled": "Devre dışı",
       "attachClientsSelectedCount": "{total} içinden {selected} seçildi",
+      "attachExistingClients": "Mevcut istemcileri bağla…",
+      "attachExistingTitle": "«{remark}» gelenine mevcut istemcileri bağla",
+      "attachExistingDesc": "Mevcut istemcileri ({count} uygun) bu gelene bağlar — aynı UUID/parola ve paylaşılan trafik. Zaten bu gelende olan istemciler atlanır.",
+      "attachExistingNoClients": "Henüz istemci yok. Önce istemci oluşturun, ardından buraya bağlayın.",
+      "attachExistingStatusAttached": "Zaten bağlı",
       "detachClients": "İstemcileri çöz",
       "detachClientsTitle": "«{remark}» gelenindeki istemcileri çöz",
       "detachClientsDesc": "Seçilen istemcileri yalnızca bu gelenden kaldırır. İstemci kayıtları korunur (tamamen kaldırmak için Delete kullanın). Kaynakta toplam {count} istemci var.",

+ 5 - 0
web/translation/uk-UA.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Пошук email або коментаря",
       "attachClientsStatusDisabled": "Вимкнено",
       "attachClientsSelectedCount": "Обрано {selected} з {total}",
+      "attachExistingClients": "Прив'язати наявних клієнтів…",
+      "attachExistingTitle": "Прив'язати наявних клієнтів до «{remark}»",
+      "attachExistingDesc": "Прив'язує наявних клієнтів (доступно {count}) до цього вхідного — той самий UUID/пароль і спільний трафік. Клієнти, уже прив'язані до нього, пропускаються.",
+      "attachExistingNoClients": "Клієнтів поки немає. Спершу створіть клієнтів, потім прив'яжіть їх тут.",
+      "attachExistingStatusAttached": "Вже прив'язано",
       "detachClients": "Від'єднати клієнтів",
       "detachClientsTitle": "Від'єднати клієнтів з «{remark}»",
       "detachClientsDesc": "Видаляє обраних клієнт(ів) лише з цього вхідного. Записи клієнтів зберігаються (використовуйте Delete для повного видалення). У джерела всього {count} клієнт(ів).",

+ 5 - 0
web/translation/vi-VN.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Tìm email hoặc ghi chú",
       "attachClientsStatusDisabled": "Đã tắt",
       "attachClientsSelectedCount": "Đã chọn {selected}/{total}",
+      "attachExistingClients": "Gắn client hiện có…",
+      "attachExistingTitle": "Gắn client hiện có vào «{remark}»",
+      "attachExistingDesc": "Gắn các client hiện có ({count} khả dụng) vào inbound này — cùng UUID/mật khẩu và lưu lượng chung. Các client đã có trên inbound sẽ được bỏ qua.",
+      "attachExistingNoClients": "Chưa có client nào. Hãy tạo client trước, rồi gắn vào đây.",
+      "attachExistingStatusAttached": "Đã gắn",
       "detachClients": "Tách client",
       "detachClientsTitle": "Tách client của «{remark}»",
       "detachClientsDesc": "Chỉ xóa client đã chọn khỏi inbound này. Hồ sơ client được giữ lại (dùng Delete để xóa hoàn toàn). Nguồn có tổng cộng {count} client.",

+ 5 - 0
web/translation/zh-CN.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "搜索邮箱或备注",
       "attachClientsStatusDisabled": "已禁用",
       "attachClientsSelectedCount": "已选 {selected}/{total}",
+      "attachExistingClients": "附加现有客户端…",
+      "attachExistingTitle": "将现有客户端附加到 “{remark}”",
+      "attachExistingDesc": "将现有客户端(可用 {count} 个)附加到此入站 — 相同 UUID/密码和共享流量。已在此入站的客户端将被跳过。",
+      "attachExistingNoClients": "尚无客户端。请先创建客户端,然后在此附加。",
+      "attachExistingStatusAttached": "已附加",
       "detachClients": "分离客户端",
       "detachClientsTitle": "从 “{remark}” 分离客户端",
       "detachClientsDesc": "仅从此入站移除选中的客户端。客户端记录保留(使用 Delete 完全移除)。源共有 {count} 个客户端。",

+ 5 - 0
web/translation/zh-TW.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "搜尋電子郵件或備註",
       "attachClientsStatusDisabled": "已停用",
       "attachClientsSelectedCount": "已選 {selected}/{total}",
+      "attachExistingClients": "附加現有客戶端…",
+      "attachExistingTitle": "將現有客戶端附加到「{remark}」",
+      "attachExistingDesc": "將現有客戶端(可用 {count} 個)附加到此入站 — 相同 UUID/密碼與共享流量。已在此入站的客戶端將被略過。",
+      "attachExistingNoClients": "尚無客戶端。請先建立客戶端,然後在此附加。",
+      "attachExistingStatusAttached": "已附加",
       "detachClients": "分離客戶端",
       "detachClientsTitle": "從「{remark}」分離客戶端",
       "detachClientsDesc": "僅從此入站移除選取的客戶端。客戶端記錄保留(用 Delete 完全移除)。來源共有 {count} 個客戶端。",