index.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
  2. import { Button, Card, Input, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
  3. import type { ColumnsType } from 'antd/es/table';
  4. import { useMemo, useState } from 'react';
  5. import { buildDomainHealth } from '../../frontend/domain-model.js';
  6. import { useI18n } from '../../frontend/i18n/react';
  7. import type { DnsCredential, Domain, SendEvent } from '../../frontend/types';
  8. import { StatusTag } from '../../components/common/StatusTag';
  9. interface DomainsPageProps {
  10. domains: Domain[];
  11. events: SendEvent[];
  12. dnsCredentials: DnsCredential[];
  13. actionLoading?: boolean;
  14. onViewDetail: (domain: Domain) => void;
  15. onApplyDns: (domain: Domain) => void;
  16. onCheck: (domain: Domain) => void;
  17. onSendTest: (domain: Domain) => void;
  18. onDelete: (domain: Domain) => void;
  19. onAddDomain: () => void;
  20. }
  21. export default function DomainsPage({
  22. domains,
  23. events,
  24. dnsCredentials,
  25. actionLoading,
  26. onViewDetail,
  27. onApplyDns,
  28. onCheck,
  29. onSendTest,
  30. onDelete,
  31. onAddDomain
  32. }: DomainsPageProps) {
  33. const { t } = useI18n();
  34. const [query, setQuery] = useState('');
  35. const [status, setStatus] = useState<string>();
  36. const credentialName = new Map(dnsCredentials.map((item) => [item.id, item.name]));
  37. const filtered = useMemo(() => {
  38. return domains.filter((domain) => {
  39. const health = buildDomainHealth(domain);
  40. const matchesQuery = !query || domain.domain.includes(query) || domain.senderHost.includes(query);
  41. const matchesStatus = !status || health.status === status;
  42. return matchesQuery && matchesStatus;
  43. });
  44. }, [domains, query, status]);
  45. const columns: ColumnsType<Domain> = [
  46. {
  47. title: t('domains.domain'),
  48. dataIndex: 'domain',
  49. fixed: 'left',
  50. width: 190,
  51. render: (value, domain) => (
  52. <Button type="link" className="table-link" onClick={() => onViewDetail(domain)}>
  53. {value}
  54. </Button>
  55. )
  56. },
  57. { title: t('domains.senderHost'), dataIndex: 'senderHost', width: 190, ellipsis: true },
  58. { title: t('domains.sendingIp'), dataIndex: 'sendingIp', width: 140 },
  59. {
  60. title: t('domains.dnsApi'),
  61. dataIndex: 'dnsCredentialId',
  62. width: 150,
  63. render: (value: number | null) => value ? <Tag>{credentialName.get(value) || value}</Tag> : <Tag>{t('common.manual')}</Tag>
  64. },
  65. recordColumn('DKIM', 'dkim'),
  66. recordColumn('SPF', 'spf'),
  67. recordColumn('DMARC', 'dmarc'),
  68. {
  69. title: t('domains.smtp'),
  70. width: 110,
  71. render: (_, domain) => <Tag color={domain.status?.verified ? 'success' : 'warning'}>{domain.status?.verified ? t('domains.sendable') : t('domains.waitingVerify')}</Tag>
  72. },
  73. {
  74. title: t('domains.lastSent'),
  75. width: 180,
  76. render: (_, domain) => {
  77. const event = events.find((item) => item.domain === domain.domain);
  78. return event ? new Date(event.createdAt).toLocaleString() : t('common.notFound');
  79. }
  80. },
  81. {
  82. title: t('domains.overallStatus'),
  83. width: 130,
  84. render: (_, domain) => {
  85. const health = buildDomainHealth(domain);
  86. return <Tag color={health.status === 'success' ? 'success' : health.status === 'warning' ? 'warning' : 'error'}>{domainHealthLabel(health.status, t)}</Tag>;
  87. }
  88. },
  89. {
  90. title: t('domains.actions'),
  91. width: 360,
  92. fixed: 'right',
  93. render: (_, domain) => (
  94. <Space size={8} wrap>
  95. <Button icon={<EyeOutlined />} onClick={() => onViewDetail(domain)}>
  96. {t('common.details')}
  97. </Button>
  98. <Button type="primary" disabled={!domain.dnsCredentialId} loading={actionLoading} onClick={() => onApplyDns(domain)}>
  99. {t('domains.oneClickDns')}
  100. </Button>
  101. <Button loading={actionLoading} onClick={() => onCheck(domain)}>
  102. {t('domains.check')}
  103. </Button>
  104. <Button onClick={() => onSendTest(domain)}>{t('domains.test')}</Button>
  105. <Popconfirm title={t('domains.deleteConfirm')} onConfirm={() => onDelete(domain)}>
  106. <Button danger icon={<DeleteOutlined />} />
  107. </Popconfirm>
  108. </Space>
  109. )
  110. }
  111. ];
  112. return (
  113. <Space direction="vertical" size={16} className="full-width">
  114. <Card>
  115. <div className="page-toolbar">
  116. <Space wrap>
  117. <Input
  118. allowClear
  119. prefix={<SearchOutlined />}
  120. placeholder={t('domains.searchPlaceholder')}
  121. value={query}
  122. onChange={(event) => setQuery(event.target.value)}
  123. className="toolbar-search"
  124. />
  125. <Select
  126. allowClear
  127. placeholder={t('domains.statusPlaceholder')}
  128. value={status}
  129. onChange={setStatus}
  130. options={[
  131. { value: 'success', label: t('domains.healthy') },
  132. { value: 'warning', label: t('domains.pending') },
  133. { value: 'error', label: t('domains.needsAction') }
  134. ]}
  135. className="toolbar-select"
  136. />
  137. </Space>
  138. <Button type="primary" onClick={onAddDomain}>
  139. {t('common.addDomain')}
  140. </Button>
  141. </div>
  142. </Card>
  143. <Card
  144. title={t('domains.title')}
  145. extra={
  146. <Typography.Text type="secondary">
  147. {filtered.length} / {domains.length}
  148. </Typography.Text>
  149. }
  150. >
  151. <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1800 }} />
  152. </Card>
  153. </Space>
  154. );
  155. }
  156. function recordColumn(title: string, key: string): ColumnsType<Domain>[number] {
  157. return {
  158. title,
  159. width: 110,
  160. render: (_, domain) => {
  161. const record = domain.status?.records?.find((item) => item.key === key);
  162. if (!record) return <StatusTag status="missing" />;
  163. return <StatusTag record={record} />;
  164. }
  165. };
  166. }
  167. function domainHealthLabel(status: string, t: (key: string) => string) {
  168. if (status === 'success') return t('domains.healthy');
  169. if (status === 'warning') return t('domains.waitingDns');
  170. return t('domains.needsAction');
  171. }