AttachClientsModal.tsx 6.4 KB

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