Преглед на файлове

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 преди 10 часа
родител
ревизия
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} 個客戶端。",