AttachClientsModal.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
  4. import type { ColumnsType } from 'antd/es/table';
  5. import { HttpUtil } from '@/utils';
  6. import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
  7. import { isInboundMultiUser } from './InboundList';
  8. interface AttachClientsModalProps {
  9. open: boolean;
  10. source: DBInbound | null;
  11. dbInbounds: DBInbound[];
  12. onClose: () => void;
  13. onAttached?: () => void;
  14. }
  15. interface BulkAttachResult {
  16. attached?: string[];
  17. skipped?: string[];
  18. errors?: string[];
  19. }
  20. interface ClientRow {
  21. email: string;
  22. comment: string;
  23. enable: boolean;
  24. }
  25. function readClientRows(settings: unknown): ClientRow[] {
  26. const parsed = coerceInboundJsonField(settings) as {
  27. clients?: Array<{ email?: string; comment?: string; enable?: boolean }>;
  28. };
  29. const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
  30. return clients
  31. .map((c) => ({
  32. email: (c?.email || '').trim(),
  33. comment: (c?.comment || '').trim(),
  34. enable: c?.enable !== false,
  35. }))
  36. .filter((r) => r.email);
  37. }
  38. export default function AttachClientsModal({
  39. open,
  40. source,
  41. dbInbounds,
  42. onClose,
  43. onAttached,
  44. }: AttachClientsModalProps) {
  45. const { t } = useTranslation();
  46. const [messageApi, messageContextHolder] = message.useMessage();
  47. const [targetIds, setTargetIds] = useState<number[]>([]);
  48. const [saving, setSaving] = useState(false);
  49. const [clientRows, setClientRows] = useState<ClientRow[]>([]);
  50. const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
  51. const [search, setSearch] = useState('');
  52. useEffect(() => {
  53. if (!open) return;
  54. const rows = source ? readClientRows(source.settings) : [];
  55. setClientRows(rows);
  56. setSelectedEmails(rows.map((r) => r.email));
  57. setTargetIds([]);
  58. setSearch('');
  59. }, [open, source]);
  60. const targetOptions = useMemo(() => {
  61. if (!source) return [];
  62. return (dbInbounds || [])
  63. .filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
  64. .map((ib) => ({ value: ib.id, label: `${ib.remark} (${ib.protocol}@${ib.port})` }));
  65. }, [dbInbounds, source]);
  66. const filteredRows = useMemo(() => {
  67. const q = search.trim().toLowerCase();
  68. if (!q) return clientRows;
  69. return clientRows.filter(
  70. (r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
  71. );
  72. }, [clientRows, search]);
  73. const columns: ColumnsType<ClientRow> = useMemo(
  74. () => [
  75. {
  76. title: t('pages.inbounds.email'),
  77. dataIndex: 'email',
  78. key: 'email',
  79. ellipsis: true,
  80. },
  81. {
  82. title: t('comment'),
  83. dataIndex: 'comment',
  84. key: 'comment',
  85. ellipsis: true,
  86. },
  87. {
  88. title: t('enable'),
  89. dataIndex: 'enable',
  90. key: 'enable',
  91. width: 90,
  92. render: (enabled: boolean) =>
  93. enabled ? (
  94. <Tag color="success">{t('enable')}</Tag>
  95. ) : (
  96. <Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
  97. ),
  98. },
  99. ],
  100. [t],
  101. );
  102. async function submit() {
  103. if (!source || targetIds.length === 0 || selectedEmails.length === 0) return;
  104. setSaving(true);
  105. try {
  106. const msg = await HttpUtil.post(
  107. '/panel/api/clients/bulkAttach',
  108. { emails: selectedEmails, inboundIds: targetIds },
  109. { headers: { 'Content-Type': 'application/json' } },
  110. );
  111. if (!msg?.success) {
  112. messageApi.error(msg?.msg || t('somethingWentWrong'));
  113. return;
  114. }
  115. const result = (msg.obj || {}) as BulkAttachResult;
  116. const attached = result.attached?.length ?? 0;
  117. const skipped = result.skipped?.length ?? 0;
  118. const errors = result.errors?.length ?? 0;
  119. if (errors > 0) {
  120. messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
  121. } else {
  122. messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
  123. }
  124. onAttached?.();
  125. onClose();
  126. } finally {
  127. setSaving(false);
  128. }
  129. }
  130. return (
  131. <Modal
  132. open={open}
  133. onCancel={onClose}
  134. onOk={submit}
  135. okButtonProps={{
  136. disabled: targetIds.length === 0 || selectedEmails.length === 0,
  137. loading: saving,
  138. }}
  139. okText={t('pages.inbounds.attachClients')}
  140. cancelText={t('cancel')}
  141. title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark ?? '' })}
  142. width={680}
  143. >
  144. {messageContextHolder}
  145. <Typography.Paragraph type="secondary">
  146. {t('pages.inbounds.attachClientsDesc', { count: clientRows.length })}
  147. </Typography.Paragraph>
  148. <Space direction="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
  149. <Typography.Text strong>{t('pages.inbounds.attachClientsSelectLabel')}</Typography.Text>
  150. <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
  151. <Input.Search
  152. allowClear
  153. value={search}
  154. onChange={(e) => setSearch(e.target.value)}
  155. placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
  156. style={{ maxWidth: 320 }}
  157. />
  158. <Typography.Text type="secondary">
  159. {t('pages.inbounds.attachClientsSelectedCount', {
  160. selected: selectedEmails.length,
  161. total: clientRows.length,
  162. })}
  163. </Typography.Text>
  164. </Space>
  165. <Table<ClientRow>
  166. size="small"
  167. rowKey="email"
  168. columns={columns}
  169. dataSource={filteredRows}
  170. pagination={false}
  171. scroll={{ y: 280 }}
  172. rowSelection={{
  173. selectedRowKeys: selectedEmails,
  174. onChange: (keys) => setSelectedEmails(keys as string[]),
  175. preserveSelectedRowKeys: true,
  176. }}
  177. />
  178. </Space>
  179. {targetOptions.length === 0 ? (
  180. <Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
  181. ) : (
  182. <Select
  183. mode="multiple"
  184. style={{ width: '100%' }}
  185. value={targetIds}
  186. onChange={setTargetIds}
  187. options={targetOptions}
  188. placeholder={t('pages.inbounds.attachClientsTargets')}
  189. optionFilterProp="label"
  190. />
  191. )}
  192. </Modal>
  193. );
  194. }