|
@@ -24,12 +24,14 @@ import {
|
|
|
Typography
|
|
Typography
|
|
|
} from 'antd';
|
|
} from 'antd';
|
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
|
-import { useEffect, useState } from 'react';
|
|
|
|
|
|
|
+import { useEffect, useRef, useState } from 'react';
|
|
|
|
|
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
|
|
|
|
|
|
import { EmptyState } from '../../components/common/EmptyState';
|
|
import { EmptyState } from '../../components/common/EmptyState';
|
|
|
import { PageHeader } from '../../components/common/PageHeader';
|
|
import { PageHeader } from '../../components/common/PageHeader';
|
|
|
import { SectionCard } from '../../components/common/SectionCard';
|
|
import { SectionCard } from '../../components/common/SectionCard';
|
|
|
import { StatusPill } from '../../components/common/StatusPill';
|
|
import { StatusPill } from '../../components/common/StatusPill';
|
|
|
|
|
+import { useAppContext } from '../../frontend/app-context';
|
|
|
import {
|
|
import {
|
|
|
adminUserStatusMeta,
|
|
adminUserStatusMeta,
|
|
|
buildMergeConfirmationText,
|
|
buildMergeConfirmationText,
|
|
@@ -44,43 +46,58 @@ import type {
|
|
|
AdminUser,
|
|
AdminUser,
|
|
|
AuditLogEntry,
|
|
AuditLogEntry,
|
|
|
SystemEmailSettings,
|
|
SystemEmailSettings,
|
|
|
- User,
|
|
|
|
|
UserMergeOptions,
|
|
UserMergeOptions,
|
|
|
UserMergePreview,
|
|
UserMergePreview,
|
|
|
UserRole,
|
|
UserRole,
|
|
|
UserStatus
|
|
UserStatus
|
|
|
} from '../../frontend/types';
|
|
} from '../../frontend/types';
|
|
|
|
|
|
|
|
-interface AdminPageProps {
|
|
|
|
|
- me: User | null;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
const statusValues: UserStatus[] = ['pending_email', 'pending_review', 'active', 'disabled'];
|
|
const statusValues: UserStatus[] = ['pending_email', 'pending_review', 'active', 'disabled'];
|
|
|
const roleValues: UserRole[] = ['user', 'admin'];
|
|
const roleValues: UserRole[] = ['user', 'admin'];
|
|
|
-
|
|
|
|
|
-const mergeOptionLabels: Array<[keyof UserMergeOptions, string]> = [
|
|
|
|
|
- ['transferDomains', '迁移域名'],
|
|
|
|
|
- ['transferDnsCredentials', '迁移 DNS 凭据'],
|
|
|
|
|
- ['transferApiTokens', '迁移 API Token'],
|
|
|
|
|
- ['transferSendEvents', '迁移发送记录'],
|
|
|
|
|
- ['transferSmtpCredential', '迁移 SMTP 凭据'],
|
|
|
|
|
- ['disableSource', '禁用源用户']
|
|
|
|
|
|
|
+const adminSections = ['users', 'resources', 'migration', 'system-email', 'audit-logs'] as const;
|
|
|
|
|
+type AdminSection = typeof adminSections[number];
|
|
|
|
|
+
|
|
|
|
|
+const mergeOptionLabels: Array<[keyof UserMergeOptions, string, string]> = [
|
|
|
|
|
+ ['transferDomains', '迁移域名', 'Transfer domains'],
|
|
|
|
|
+ ['transferDnsCredentials', '迁移 DNS 凭据', 'Transfer DNS credentials'],
|
|
|
|
|
+ ['transferApiTokens', '迁移 API Token', 'Transfer API tokens'],
|
|
|
|
|
+ ['transferSendEvents', '迁移发送记录', 'Transfer sending activity'],
|
|
|
|
|
+ ['transferSmtpCredential', '迁移 SMTP 凭据', 'Transfer SMTP credential'],
|
|
|
|
|
+ ['disableSource', '禁用源用户', 'Disable source user']
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
-export default function AdminPage({ me }: AdminPageProps) {
|
|
|
|
|
|
|
+export default function AdminPage() {
|
|
|
const { message, modal } = AntApp.useApp();
|
|
const { message, modal } = AntApp.useApp();
|
|
|
- const { t } = useI18n();
|
|
|
|
|
|
|
+ const { locale, t } = useI18n();
|
|
|
|
|
+ const { user: me } = useAppContext();
|
|
|
|
|
+ const navigate = useNavigate();
|
|
|
|
|
+ const params = useParams<{ section?: string }>();
|
|
|
|
|
+ const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
|
+ const auditQuery = searchParams.toString();
|
|
|
|
|
+ const activeSection: AdminSection = adminSections.includes(params.section as AdminSection)
|
|
|
|
|
+ ? params.section as AdminSection
|
|
|
|
|
+ : 'users';
|
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
|
const [inventory, setInventory] = useState<AdminResourceInventory | null>(null);
|
|
const [inventory, setInventory] = useState<AdminResourceInventory | null>(null);
|
|
|
const [systemEmail, setSystemEmail] = useState<SystemEmailSettings | null>(null);
|
|
const [systemEmail, setSystemEmail] = useState<SystemEmailSettings | null>(null);
|
|
|
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
|
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
|
|
- const [auditQuery, setAuditQuery] = useState('');
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
- const [actionLoading, setActionLoading] = useState(false);
|
|
|
|
|
|
|
+ const [actionKeys, setActionKeys] = useState<Set<string>>(() => new Set());
|
|
|
|
|
+ const loadedSections = useRef(new Set<AdminSection>());
|
|
|
|
|
+ const auditRequestId = useRef(0);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!params.section || !adminSections.includes(params.section as AdminSection)) {
|
|
|
|
|
+ navigate('/admin/users', { replace: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [navigate, params.section]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (me?.role === 'admin') void loadAdminData();
|
|
|
|
|
- }, [me?.role]);
|
|
|
|
|
|
|
+ if (me?.role === 'admin') void loadSection(activeSection, activeSection === 'audit-logs', auditQuery);
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ if (activeSection === 'audit-logs') auditRequestId.current += 1;
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [activeSection, auditQuery, me?.role]);
|
|
|
|
|
|
|
|
if (me?.role !== 'admin') {
|
|
if (me?.role !== 'admin') {
|
|
|
return (
|
|
return (
|
|
@@ -93,36 +110,54 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function loadAdminData(query = auditQuery) {
|
|
|
|
|
- setLoading(true);
|
|
|
|
|
|
|
+ async function loadSection(section: AdminSection, force = false, query = auditQuery, showLoading = true) {
|
|
|
|
|
+ if (!force && loadedSections.current.has(section)) {
|
|
|
|
|
+ if (showLoading) setLoading(false);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let currentAuditRequestId: number | null = null;
|
|
|
|
|
+ if (showLoading) setLoading(true);
|
|
|
try {
|
|
try {
|
|
|
- const [usersResult, resourcesResult, emailResult, auditResult] = await Promise.all([
|
|
|
|
|
- api.adminUsers(),
|
|
|
|
|
- api.adminResources(),
|
|
|
|
|
- api.adminSystemEmail(),
|
|
|
|
|
- api.adminAuditLogs(query)
|
|
|
|
|
- ]);
|
|
|
|
|
- setUsers(usersResult.users || []);
|
|
|
|
|
- setInventory(resourcesResult.inventory || null);
|
|
|
|
|
- setSystemEmail(emailResult.settings || null);
|
|
|
|
|
- setAuditLogs(auditResult.logs || []);
|
|
|
|
|
|
|
+ if (section === 'users' || section === 'migration') {
|
|
|
|
|
+ const result = await api.adminUsers();
|
|
|
|
|
+ setUsers(result.users || []);
|
|
|
|
|
+ } else if (section === 'resources') {
|
|
|
|
|
+ const [usersResult, resourcesResult] = await Promise.all([api.adminUsers(), api.adminResources()]);
|
|
|
|
|
+ setUsers(usersResult.users || []);
|
|
|
|
|
+ setInventory(resourcesResult.inventory || null);
|
|
|
|
|
+ } else if (section === 'system-email') {
|
|
|
|
|
+ const result = await api.adminSystemEmail();
|
|
|
|
|
+ setSystemEmail(result.settings || null);
|
|
|
|
|
+ } else if (section === 'audit-logs') {
|
|
|
|
|
+ currentAuditRequestId = ++auditRequestId.current;
|
|
|
|
|
+ const [usersResult, auditResult] = await Promise.all([api.adminUsers(), api.adminAuditLogs(query)]);
|
|
|
|
|
+ if (currentAuditRequestId !== auditRequestId.current) return;
|
|
|
|
|
+ setUsers(usersResult.users || []);
|
|
|
|
|
+ setAuditLogs(auditResult.logs || []);
|
|
|
|
|
+ }
|
|
|
|
|
+ loadedSections.current.add(section);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- message.error(error instanceof Error ? error.message : '管理员数据加载失败');
|
|
|
|
|
|
|
+ if (currentAuditRequestId !== null && currentAuditRequestId !== auditRequestId.current) return;
|
|
|
|
|
+ message.error(error instanceof Error ? error.message : tr(locale, '管理员数据加载失败', 'Failed to load admin data'));
|
|
|
} finally {
|
|
} finally {
|
|
|
- setLoading(false);
|
|
|
|
|
|
|
+ if (showLoading && (currentAuditRequestId === null || currentAuditRequestId === auditRequestId.current)) setLoading(false);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function runAction(action: () => Promise<unknown>, success: string, refresh = true) {
|
|
|
|
|
- setActionLoading(true);
|
|
|
|
|
|
|
+ async function runAction(key: string, action: () => Promise<unknown>, success: string, refresh = true) {
|
|
|
|
|
+ setActionKeys((current) => new Set(current).add(key));
|
|
|
try {
|
|
try {
|
|
|
await action();
|
|
await action();
|
|
|
message.success(success);
|
|
message.success(success);
|
|
|
- if (refresh) await loadAdminData();
|
|
|
|
|
|
|
+ if (refresh) await loadSection(activeSection, true, auditQuery, false);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- message.error(error instanceof Error ? error.message : '操作失败');
|
|
|
|
|
|
|
+ message.error(error instanceof Error ? error.message : tr(locale, '操作失败', 'Operation failed'));
|
|
|
} finally {
|
|
} finally {
|
|
|
- setActionLoading(false);
|
|
|
|
|
|
|
+ setActionKeys((current) => {
|
|
|
|
|
+ const next = new Set(current);
|
|
|
|
|
+ next.delete(key);
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -136,16 +171,13 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function searchAuditLogs(query: string) {
|
|
async function searchAuditLogs(query: string) {
|
|
|
- setAuditQuery(query);
|
|
|
|
|
- setLoading(true);
|
|
|
|
|
- try {
|
|
|
|
|
- const result = await api.adminAuditLogs(query);
|
|
|
|
|
- setAuditLogs(result.logs || []);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- message.error(error instanceof Error ? error.message : '审计日志加载失败');
|
|
|
|
|
- } finally {
|
|
|
|
|
- setLoading(false);
|
|
|
|
|
|
|
+ const next = new URLSearchParams(query);
|
|
|
|
|
+ const nextQuery = next.toString();
|
|
|
|
|
+ if (nextQuery === auditQuery) {
|
|
|
|
|
+ await loadSection('audit-logs', true, nextQuery);
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
+ setSearchParams(next);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const tabItems = [
|
|
const tabItems = [
|
|
@@ -155,19 +187,20 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
children: (
|
|
children: (
|
|
|
<AdminUsers
|
|
<AdminUsers
|
|
|
users={users}
|
|
users={users}
|
|
|
- loading={loading || actionLoading}
|
|
|
|
|
- onApprove={(user) => confirm(`确认审批用户 ${user.username}?`, () =>
|
|
|
|
|
- runAction(() => api.approveAdminUser(user.id), '用户已审批')
|
|
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ actionKeys={actionKeys}
|
|
|
|
|
+ onApprove={(user) => confirm(tr(locale, `确认审批用户 ${user.username}?`, `Approve user ${user.username}?`), () =>
|
|
|
|
|
+ runAction(`approve:${user.id}`, () => api.approveAdminUser(user.id), tr(locale, '用户已审批', 'User approved'))
|
|
|
)}
|
|
)}
|
|
|
- onResendVerification={(user) => runAction(() => api.resendAdminVerification(user.id), '验证邮件请求已提交')}
|
|
|
|
|
- onPasswordReset={(user) => confirm(`确认给 ${user.email} 发送密码重置邮件?`, () =>
|
|
|
|
|
- runAction(() => api.sendAdminPasswordReset(user.id), '密码重置邮件请求已提交')
|
|
|
|
|
|
|
+ onResendVerification={(user) => runAction(`resend:${user.id}`, () => api.resendAdminVerification(user.id), tr(locale, '验证邮件请求已提交', 'Verification email requested'))}
|
|
|
|
|
+ onPasswordReset={(user) => confirm(tr(locale, `确认给 ${user.email} 发送密码重置邮件?`, `Send a password reset email to ${user.email}?`), () =>
|
|
|
|
|
+ runAction(`reset:${user.id}`, () => api.sendAdminPasswordReset(user.id), tr(locale, '密码重置邮件请求已提交', 'Password reset email requested'))
|
|
|
)}
|
|
)}
|
|
|
- onTemporaryPassword={(user, password) => confirm(`确认为用户 ${user.username} 设置临时密码?`, () =>
|
|
|
|
|
- runAction(() => api.setAdminTemporaryPassword(user.id, password), '临时密码已设置')
|
|
|
|
|
|
|
+ onTemporaryPassword={(user, password) => confirm(tr(locale, `确认为用户 ${user.username} 设置临时密码?`, `Set a temporary password for ${user.username}?`), () =>
|
|
|
|
|
+ runAction(`temporary-password:${user.id}`, () => api.setAdminTemporaryPassword(user.id, password), tr(locale, '临时密码已设置', 'Temporary password set'))
|
|
|
)}
|
|
)}
|
|
|
- onUpdateUser={(user, patch) => confirm(`确认更新用户 ${user.username}?`, () =>
|
|
|
|
|
- runAction(() => api.updateAdminUser(user.id, patch), '用户已更新')
|
|
|
|
|
|
|
+ onUpdateUser={(user, patch) => confirm(tr(locale, `确认更新用户 ${user.username}?`, `Update user ${user.username}?`), () =>
|
|
|
|
|
+ runAction(`update:${user.id}`, () => api.updateAdminUser(user.id, patch), tr(locale, '用户已更新', 'User updated'))
|
|
|
)}
|
|
)}
|
|
|
/>
|
|
/>
|
|
|
)
|
|
)
|
|
@@ -179,15 +212,16 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
<AdminResources
|
|
<AdminResources
|
|
|
users={users}
|
|
users={users}
|
|
|
inventory={inventory}
|
|
inventory={inventory}
|
|
|
- loading={loading || actionLoading}
|
|
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ actionKeys={actionKeys}
|
|
|
onTransferDomain={(domainId, values) =>
|
|
onTransferDomain={(domainId, values) =>
|
|
|
- runAction(() => api.transferAdminDomain(domainId, values), '域名已迁移')
|
|
|
|
|
|
|
+ runAction(`transfer-domain:${domainId}`, () => api.transferAdminDomain(domainId, values), tr(locale, '域名已迁移', 'Domain transferred'))
|
|
|
}
|
|
}
|
|
|
onTransferDnsCredential={(credentialId, values) =>
|
|
onTransferDnsCredential={(credentialId, values) =>
|
|
|
- runAction(() => api.transferAdminDnsCredential(credentialId, values), 'DNS 凭据已迁移')
|
|
|
|
|
|
|
+ runAction(`transfer-dns:${credentialId}`, () => api.transferAdminDnsCredential(credentialId, values), tr(locale, 'DNS 凭据已迁移', 'DNS credential transferred'))
|
|
|
}
|
|
}
|
|
|
onTransferApiTokens={(values) =>
|
|
onTransferApiTokens={(values) =>
|
|
|
- runAction(() => api.transferAdminApiTokens(values), 'API Token 已迁移')
|
|
|
|
|
|
|
+ runAction('transfer-token', () => api.transferAdminApiTokens(values), tr(locale, 'API Token 已迁移', 'API tokens transferred'))
|
|
|
}
|
|
}
|
|
|
/>
|
|
/>
|
|
|
)
|
|
)
|
|
@@ -198,9 +232,10 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
children: (
|
|
children: (
|
|
|
<AdminMigration
|
|
<AdminMigration
|
|
|
users={users}
|
|
users={users}
|
|
|
- loading={loading || actionLoading}
|
|
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ actionKeys={actionKeys}
|
|
|
onPreview={(values) => api.previewUserMerge(values)}
|
|
onPreview={(values) => api.previewUserMerge(values)}
|
|
|
- onExecute={(values) => runAction(() => api.executeUserMerge(values), '用户资源已合并')}
|
|
|
|
|
|
|
+ onExecute={(values) => runAction('merge', () => api.executeUserMerge(values), tr(locale, '用户资源已合并', 'User resources merged'))}
|
|
|
/>
|
|
/>
|
|
|
)
|
|
)
|
|
|
},
|
|
},
|
|
@@ -210,9 +245,10 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
children: (
|
|
children: (
|
|
|
<AdminSystemEmail
|
|
<AdminSystemEmail
|
|
|
settings={systemEmail}
|
|
settings={systemEmail}
|
|
|
- loading={loading || actionLoading}
|
|
|
|
|
- onSave={(values) => runAction(() => api.saveAdminSystemEmail(values), '系统邮件配置已保存')}
|
|
|
|
|
- onTest={(to) => runAction(() => api.testAdminSystemEmail(to), '测试邮件请求已提交', false)}
|
|
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ actionKeys={actionKeys}
|
|
|
|
|
+ onSave={(values) => runAction('system-email:save', () => api.saveAdminSystemEmail(values), tr(locale, '系统邮件配置已保存', 'System email settings saved'))}
|
|
|
|
|
+ onTest={(to) => runAction('system-email:test', () => api.testAdminSystemEmail(to), tr(locale, '测试邮件请求已提交', 'Test email requested'), false)}
|
|
|
/>
|
|
/>
|
|
|
)
|
|
)
|
|
|
},
|
|
},
|
|
@@ -223,6 +259,7 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
<AdminAuditLogs
|
|
<AdminAuditLogs
|
|
|
logs={auditLogs}
|
|
logs={auditLogs}
|
|
|
users={users}
|
|
users={users}
|
|
|
|
|
+ query={auditQuery}
|
|
|
loading={loading}
|
|
loading={loading}
|
|
|
onSearch={searchAuditLogs}
|
|
onSearch={searchAuditLogs}
|
|
|
/>
|
|
/>
|
|
@@ -235,13 +272,18 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
<PageHeader
|
|
<PageHeader
|
|
|
title={t('admin.title')}
|
|
title={t('admin.title')}
|
|
|
extra={
|
|
extra={
|
|
|
- <Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadAdminData()}>
|
|
|
|
|
|
|
+ <Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadSection(activeSection, true)}>
|
|
|
{t('common.refresh')}
|
|
{t('common.refresh')}
|
|
|
</Button>
|
|
</Button>
|
|
|
}
|
|
}
|
|
|
/>
|
|
/>
|
|
|
<SectionCard>
|
|
<SectionCard>
|
|
|
- <Tabs items={tabItems} />
|
|
|
|
|
|
|
+ <Tabs
|
|
|
|
|
+ activeKey={activeSection}
|
|
|
|
|
+ items={tabItems}
|
|
|
|
|
+ destroyOnHidden
|
|
|
|
|
+ onChange={(key) => navigate(`/admin/${key}`)}
|
|
|
|
|
+ />
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
</Space>
|
|
</Space>
|
|
|
);
|
|
);
|
|
@@ -250,6 +292,7 @@ export default function AdminPage({ me }: AdminPageProps) {
|
|
|
function AdminUsers({
|
|
function AdminUsers({
|
|
|
users,
|
|
users,
|
|
|
loading,
|
|
loading,
|
|
|
|
|
+ actionKeys,
|
|
|
onApprove,
|
|
onApprove,
|
|
|
onResendVerification,
|
|
onResendVerification,
|
|
|
onPasswordReset,
|
|
onPasswordReset,
|
|
@@ -258,19 +301,21 @@ function AdminUsers({
|
|
|
}: {
|
|
}: {
|
|
|
users: AdminUser[];
|
|
users: AdminUser[];
|
|
|
loading: boolean;
|
|
loading: boolean;
|
|
|
|
|
+ actionKeys: ReadonlySet<string>;
|
|
|
onApprove: (user: AdminUser) => void;
|
|
onApprove: (user: AdminUser) => void;
|
|
|
onResendVerification: (user: AdminUser) => void;
|
|
onResendVerification: (user: AdminUser) => void;
|
|
|
onPasswordReset: (user: AdminUser) => void;
|
|
onPasswordReset: (user: AdminUser) => void;
|
|
|
onTemporaryPassword: (user: AdminUser, password: string) => void;
|
|
onTemporaryPassword: (user: AdminUser, password: string) => void;
|
|
|
onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
|
|
onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
const [tempUser, setTempUser] = useState<AdminUser | null>(null);
|
|
const [tempUser, setTempUser] = useState<AdminUser | null>(null);
|
|
|
const [form] = Form.useForm<{ password: string }>();
|
|
const [form] = Form.useForm<{ password: string }>();
|
|
|
|
|
|
|
|
const columns: ColumnsType<AdminUser> = [
|
|
const columns: ColumnsType<AdminUser> = [
|
|
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
|
|
{
|
|
{
|
|
|
- title: '用户',
|
|
|
|
|
|
|
+ title: tr(locale, '用户', 'User'),
|
|
|
render: (_, user) => (
|
|
render: (_, user) => (
|
|
|
<Space direction="vertical" size={0}>
|
|
<Space direction="vertical" size={0}>
|
|
|
<Typography.Text strong>{user.username}</Typography.Text>
|
|
<Typography.Text strong>{user.username}</Typography.Text>
|
|
@@ -279,68 +324,83 @@ function AdminUsers({
|
|
|
)
|
|
)
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- title: '状态',
|
|
|
|
|
|
|
+ title: tr(locale, '状态', 'Status'),
|
|
|
dataIndex: 'status',
|
|
dataIndex: 'status',
|
|
|
width: 180,
|
|
width: 180,
|
|
|
- render: (_, user) => (
|
|
|
|
|
- <Select
|
|
|
|
|
- value={user.status}
|
|
|
|
|
- options={statusValues.map((value) => ({ value, label: adminUserStatusMeta(value).label }))}
|
|
|
|
|
- onChange={(status) => onUpdateUser(user, { status })}
|
|
|
|
|
- className="table-select"
|
|
|
|
|
- />
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ render: (_, user) => {
|
|
|
|
|
+ const pending = userMutationPending(actionKeys, user.id);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Select
|
|
|
|
|
+ value={user.status}
|
|
|
|
|
+ loading={actionKeys.has(`update:${user.id}`)}
|
|
|
|
|
+ disabled={pending}
|
|
|
|
|
+ options={statusValues.map((value) => ({ value, label: userStatusLabel(value, locale) }))}
|
|
|
|
|
+ onChange={(status) => onUpdateUser(user, { status })}
|
|
|
|
|
+ className="table-select"
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- title: '角色',
|
|
|
|
|
|
|
+ title: tr(locale, '角色', 'Role'),
|
|
|
dataIndex: 'role',
|
|
dataIndex: 'role',
|
|
|
width: 140,
|
|
width: 140,
|
|
|
- render: (_, user) => (
|
|
|
|
|
- <Select
|
|
|
|
|
- value={user.role}
|
|
|
|
|
- options={roleValues.map((value) => ({ value, label: value }))}
|
|
|
|
|
- onChange={(role) => onUpdateUser(user, { role })}
|
|
|
|
|
- className="table-select"
|
|
|
|
|
- />
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ render: (_, user) => {
|
|
|
|
|
+ const pending = userMutationPending(actionKeys, user.id);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Select
|
|
|
|
|
+ value={user.role}
|
|
|
|
|
+ loading={actionKeys.has(`update:${user.id}`)}
|
|
|
|
|
+ disabled={pending}
|
|
|
|
|
+ options={roleValues.map((value) => ({ value, label: value }))}
|
|
|
|
|
+ onChange={(role) => onUpdateUser(user, { role })}
|
|
|
|
|
+ className="table-select"
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- title: '资源',
|
|
|
|
|
|
|
+ title: tr(locale, '资源', 'Resources'),
|
|
|
render: (_, user) => <ResourceCountTags counts={user.resourceCounts} />
|
|
render: (_, user) => <ResourceCountTags counts={user.resourceCounts} />
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- title: '创建时间',
|
|
|
|
|
|
|
+ title: tr(locale, '创建时间', 'Created at'),
|
|
|
dataIndex: 'createdAt',
|
|
dataIndex: 'createdAt',
|
|
|
width: 190,
|
|
width: 190,
|
|
|
render: formatDate
|
|
render: formatDate
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- title: '操作',
|
|
|
|
|
|
|
+ title: tr(locale, '操作', 'Actions'),
|
|
|
width: 380,
|
|
width: 380,
|
|
|
- render: (_, user) => (
|
|
|
|
|
- <Space wrap>
|
|
|
|
|
- <Button
|
|
|
|
|
- icon={<CheckCircleOutlined />}
|
|
|
|
|
- disabled={user.status !== 'pending_review'}
|
|
|
|
|
- onClick={() => onApprove(user)}
|
|
|
|
|
- >
|
|
|
|
|
- 审批
|
|
|
|
|
- </Button>
|
|
|
|
|
- <Button
|
|
|
|
|
- icon={<MailOutlined />}
|
|
|
|
|
- disabled={user.status !== 'pending_email'}
|
|
|
|
|
- onClick={() => onResendVerification(user)}
|
|
|
|
|
- >
|
|
|
|
|
- 重发验证
|
|
|
|
|
- </Button>
|
|
|
|
|
- <Button icon={<SendOutlined />} onClick={() => onPasswordReset(user)}>
|
|
|
|
|
- 重置邮件
|
|
|
|
|
- </Button>
|
|
|
|
|
- <Button icon={<UserSwitchOutlined />} onClick={() => setTempUser(user)}>
|
|
|
|
|
- 临时密码
|
|
|
|
|
- </Button>
|
|
|
|
|
- </Space>
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ render: (_, user) => {
|
|
|
|
|
+ const pending = userMutationPending(actionKeys, user.id);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Space wrap>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ icon={<CheckCircleOutlined />}
|
|
|
|
|
+ disabled={pending || user.status !== 'pending_review'}
|
|
|
|
|
+ loading={actionKeys.has(`approve:${user.id}`)}
|
|
|
|
|
+ onClick={() => onApprove(user)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {tr(locale, '审批', 'Approve')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ icon={<MailOutlined />}
|
|
|
|
|
+ disabled={pending || user.status !== 'pending_email'}
|
|
|
|
|
+ loading={actionKeys.has(`resend:${user.id}`)}
|
|
|
|
|
+ onClick={() => onResendVerification(user)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {tr(locale, '重发验证', 'Resend verification')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button disabled={pending} icon={<SendOutlined />} loading={actionKeys.has(`reset:${user.id}`)} onClick={() => onPasswordReset(user)}>
|
|
|
|
|
+ {tr(locale, '重置邮件', 'Reset email')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button disabled={pending} icon={<UserSwitchOutlined />} loading={actionKeys.has(`temporary-password:${user.id}`)} onClick={() => setTempUser(user)}>
|
|
|
|
|
+ {tr(locale, '临时密码', 'Temporary password')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
];
|
|
];
|
|
|
|
|
|
|
@@ -362,17 +422,17 @@ function AdminUsers({
|
|
|
scroll={{ x: 1100 }}
|
|
scroll={{ x: 1100 }}
|
|
|
/>
|
|
/>
|
|
|
<Modal
|
|
<Modal
|
|
|
- title={tempUser ? `设置临时密码 · ${tempUser.username}` : '设置临时密码'}
|
|
|
|
|
|
|
+ title={tempUser ? `${tr(locale, '设置临时密码', 'Set temporary password')} · ${tempUser.username}` : tr(locale, '设置临时密码', 'Set temporary password')}
|
|
|
open={Boolean(tempUser)}
|
|
open={Boolean(tempUser)}
|
|
|
- confirmLoading={loading}
|
|
|
|
|
|
|
+ confirmLoading={actionKeys.has(`temporary-password:${tempUser?.id}`)}
|
|
|
onCancel={() => setTempUser(null)}
|
|
onCancel={() => setTempUser(null)}
|
|
|
onOk={submitTemporaryPassword}
|
|
onOk={submitTemporaryPassword}
|
|
|
>
|
|
>
|
|
|
<Form form={form} layout="vertical">
|
|
<Form form={form} layout="vertical">
|
|
|
<Form.Item
|
|
<Form.Item
|
|
|
name="password"
|
|
name="password"
|
|
|
- label="临时密码"
|
|
|
|
|
- rules={[{ required: true, min: 8, message: '密码至少需要 8 位。' }]}
|
|
|
|
|
|
|
+ label={tr(locale, '临时密码', 'Temporary password')}
|
|
|
|
|
+ rules={[{ required: true, min: 8, message: tr(locale, '密码至少需要 8 位。', 'Password must be at least 8 characters.') }]}
|
|
|
>
|
|
>
|
|
|
<Input.Password autoComplete="new-password" />
|
|
<Input.Password autoComplete="new-password" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
@@ -386,6 +446,7 @@ function AdminResources({
|
|
|
users,
|
|
users,
|
|
|
inventory,
|
|
inventory,
|
|
|
loading,
|
|
loading,
|
|
|
|
|
+ actionKeys,
|
|
|
onTransferDomain,
|
|
onTransferDomain,
|
|
|
onTransferDnsCredential,
|
|
onTransferDnsCredential,
|
|
|
onTransferApiTokens
|
|
onTransferApiTokens
|
|
@@ -393,13 +454,17 @@ function AdminResources({
|
|
|
users: AdminUser[];
|
|
users: AdminUser[];
|
|
|
inventory: AdminResourceInventory | null;
|
|
inventory: AdminResourceInventory | null;
|
|
|
loading: boolean;
|
|
loading: boolean;
|
|
|
|
|
+ actionKeys: ReadonlySet<string>;
|
|
|
onTransferDomain: (domainId: number, values: { targetUserId: number; dnsCredentialMode?: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) => Promise<void>;
|
|
onTransferDomain: (domainId: number, values: { targetUserId: number; dnsCredentialMode?: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) => Promise<void>;
|
|
|
onTransferDnsCredential: (credentialId: number, values: { targetUserId: number }) => Promise<void>;
|
|
onTransferDnsCredential: (credentialId: number, values: { targetUserId: number }) => Promise<void>;
|
|
|
onTransferApiTokens: (values: { tokenIds: number[]; targetUserId: number }) => Promise<void>;
|
|
onTransferApiTokens: (values: { tokenIds: number[]; targetUserId: number }) => Promise<void>;
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
const [domainForm] = Form.useForm<{ domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }>();
|
|
const [domainForm] = Form.useForm<{ domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }>();
|
|
|
const [dnsForm] = Form.useForm<{ credentialId: number; targetUserId: number }>();
|
|
const [dnsForm] = Form.useForm<{ credentialId: number; targetUserId: number }>();
|
|
|
const [tokenForm] = Form.useForm<{ tokenIds: number[]; targetUserId: number }>();
|
|
const [tokenForm] = Form.useForm<{ tokenIds: number[]; targetUserId: number }>();
|
|
|
|
|
+ const selectedDomainId = Form.useWatch('domainId', domainForm);
|
|
|
|
|
+ const selectedCredentialId = Form.useWatch('credentialId', dnsForm);
|
|
|
const groups = inventory?.users || [];
|
|
const groups = inventory?.users || [];
|
|
|
const targetOptions = users
|
|
const targetOptions = users
|
|
|
.filter((user) => user.status !== 'disabled')
|
|
.filter((user) => user.status !== 'disabled')
|
|
@@ -410,7 +475,7 @@ function AdminResources({
|
|
|
|
|
|
|
|
const groupColumns: ColumnsType<AdminResourceInventory['users'][number]> = [
|
|
const groupColumns: ColumnsType<AdminResourceInventory['users'][number]> = [
|
|
|
{
|
|
{
|
|
|
- title: '用户',
|
|
|
|
|
|
|
+ title: tr(locale, '用户', 'User'),
|
|
|
render: (_, group) => (
|
|
render: (_, group) => (
|
|
|
<Space>
|
|
<Space>
|
|
|
<Typography.Text strong>{group.user.username}</Typography.Text>
|
|
<Typography.Text strong>{group.user.username}</Typography.Text>
|
|
@@ -418,23 +483,23 @@ function AdminResources({
|
|
|
</Space>
|
|
</Space>
|
|
|
)
|
|
)
|
|
|
},
|
|
},
|
|
|
- { title: '资源', render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
|
|
|
|
|
- { title: '发送记录', dataIndex: 'sendEventCount', width: 120 },
|
|
|
|
|
- { title: '入站邮件', dataIndex: 'inboundMessageCount', width: 120 },
|
|
|
|
|
|
|
+ { title: tr(locale, '资源', 'Resources'), render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
|
|
|
|
|
+ { title: tr(locale, '发送记录', 'Sending activity'), dataIndex: 'sendEventCount', width: 120 },
|
|
|
|
|
+ { title: tr(locale, '入站邮件', 'Inbound mail'), dataIndex: 'inboundMessageCount', width: 120 },
|
|
|
{
|
|
{
|
|
|
title: 'SMTP',
|
|
title: 'SMTP',
|
|
|
width: 120,
|
|
width: 120,
|
|
|
render: (_, group) => (
|
|
render: (_, group) => (
|
|
|
group.smtpCredential
|
|
group.smtpCredential
|
|
|
- ? <StatusPill tone="success">已配置</StatusPill>
|
|
|
|
|
- : <StatusPill tone="neutral">无</StatusPill>
|
|
|
|
|
|
|
+ ? <StatusPill tone="success">{tr(locale, '已配置', 'Configured')}</StatusPill>
|
|
|
|
|
+ : <StatusPill tone="neutral">{tr(locale, '无', 'None')}</StatusPill>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
async function submitDomainTransfer(values: { domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) {
|
|
async function submitDomainTransfer(values: { domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) {
|
|
|
Modal.confirm({
|
|
Modal.confirm({
|
|
|
- title: '确认迁移该域名?',
|
|
|
|
|
|
|
+ title: tr(locale, '确认迁移该域名?', 'Transfer this domain?'),
|
|
|
onOk: async () => {
|
|
onOk: async () => {
|
|
|
await onTransferDomain(values.domainId, {
|
|
await onTransferDomain(values.domainId, {
|
|
|
targetUserId: values.targetUserId,
|
|
targetUserId: values.targetUserId,
|
|
@@ -447,7 +512,7 @@ function AdminResources({
|
|
|
|
|
|
|
|
async function submitDnsTransfer(values: { credentialId: number; targetUserId: number }) {
|
|
async function submitDnsTransfer(values: { credentialId: number; targetUserId: number }) {
|
|
|
Modal.confirm({
|
|
Modal.confirm({
|
|
|
- title: '确认迁移该 DNS 凭据?',
|
|
|
|
|
|
|
+ title: tr(locale, '确认迁移该 DNS 凭据?', 'Transfer this DNS credential?'),
|
|
|
onOk: async () => {
|
|
onOk: async () => {
|
|
|
await onTransferDnsCredential(values.credentialId, { targetUserId: values.targetUserId });
|
|
await onTransferDnsCredential(values.credentialId, { targetUserId: values.targetUserId });
|
|
|
dnsForm.resetFields();
|
|
dnsForm.resetFields();
|
|
@@ -457,7 +522,7 @@ function AdminResources({
|
|
|
|
|
|
|
|
async function submitTokenTransfer(values: { tokenIds: number[]; targetUserId: number }) {
|
|
async function submitTokenTransfer(values: { tokenIds: number[]; targetUserId: number }) {
|
|
|
Modal.confirm({
|
|
Modal.confirm({
|
|
|
- title: `确认迁移 ${values.tokenIds.length} 个 API Token?`,
|
|
|
|
|
|
|
+ title: tr(locale, `确认迁移 ${values.tokenIds.length} 个 API Token?`, `Transfer ${values.tokenIds.length} API tokens?`),
|
|
|
onOk: async () => {
|
|
onOk: async () => {
|
|
|
await onTransferApiTokens({ tokenIds: values.tokenIds, targetUserId: values.targetUserId });
|
|
await onTransferApiTokens({ tokenIds: values.tokenIds, targetUserId: values.targetUserId });
|
|
|
tokenForm.resetFields();
|
|
tokenForm.resetFields();
|
|
@@ -471,13 +536,13 @@ function AdminResources({
|
|
|
<Alert
|
|
<Alert
|
|
|
type="warning"
|
|
type="warning"
|
|
|
showIcon
|
|
showIcon
|
|
|
- message={`发现 ${inventory.warnings.length} 个 DNS 凭据归属不一致的域名`}
|
|
|
|
|
|
|
+ message={tr(locale, `发现 ${inventory.warnings.length} 个 DNS 凭据归属不一致的域名`, `${inventory.warnings.length} domains have mismatched DNS credential ownership`)}
|
|
|
/>
|
|
/>
|
|
|
) : null}
|
|
) : null}
|
|
|
<div className="form-grid three">
|
|
<div className="form-grid three">
|
|
|
- <SectionCard title="迁移域名">
|
|
|
|
|
- <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading}>
|
|
|
|
|
- <Form.Item name="domainId" label="域名" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <SectionCard title={tr(locale, '迁移域名', 'Transfer domain')}>
|
|
|
|
|
+ <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading || actionKeys.has(`transfer-domain:${selectedDomainId}`)}>
|
|
|
|
|
+ <Form.Item name="domainId" label={tr(locale, '域名', 'Domain')} rules={[{ required: true }]}>
|
|
|
<Select
|
|
<Select
|
|
|
showSearch
|
|
showSearch
|
|
|
optionFilterProp="label"
|
|
optionFilterProp="label"
|
|
@@ -487,24 +552,24 @@ function AdminResources({
|
|
|
}))}
|
|
}))}
|
|
|
/>
|
|
/>
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
|
|
|
<Select options={targetOptions} />
|
|
<Select options={targetOptions} />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="dnsCredentialMode" label="DNS 凭据" initialValue="domain_only">
|
|
|
|
|
|
|
+ <Form.Item name="dnsCredentialMode" label={tr(locale, 'DNS 凭据', 'DNS credential')} initialValue="domain_only">
|
|
|
<Select
|
|
<Select
|
|
|
options={[
|
|
options={[
|
|
|
- { value: 'domain_only', label: '仅迁移域名' },
|
|
|
|
|
- { value: 'with_dns_credential', label: '连同 DNS 凭据迁移' },
|
|
|
|
|
- { value: 'clear_dns_credential', label: '清空 DNS 凭据绑定' }
|
|
|
|
|
|
|
+ { value: 'domain_only', label: tr(locale, '仅迁移域名', 'Domain only') },
|
|
|
|
|
+ { value: 'with_dns_credential', label: tr(locale, '连同 DNS 凭据迁移', 'Include DNS credential') },
|
|
|
|
|
+ { value: 'clear_dns_credential', label: tr(locale, '清空 DNS 凭据绑定', 'Clear DNS credential binding') }
|
|
|
]}
|
|
]}
|
|
|
/>
|
|
/>
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
|
|
|
|
|
|
|
+ <Button type="primary" htmlType="submit" loading={actionKeys.has(`transfer-domain:${selectedDomainId}`)}>{tr(locale, '执行迁移', 'Transfer')}</Button>
|
|
|
</Form>
|
|
</Form>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
- <SectionCard title="迁移 DNS 凭据">
|
|
|
|
|
- <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading}>
|
|
|
|
|
- <Form.Item name="credentialId" label="DNS 凭据" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <SectionCard title={tr(locale, '迁移 DNS 凭据', 'Transfer DNS credential')}>
|
|
|
|
|
+ <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading || actionKeys.has(`transfer-dns:${selectedCredentialId}`)}>
|
|
|
|
|
+ <Form.Item name="credentialId" label={tr(locale, 'DNS 凭据', 'DNS credential')} rules={[{ required: true }]}>
|
|
|
<Select
|
|
<Select
|
|
|
showSearch
|
|
showSearch
|
|
|
optionFilterProp="label"
|
|
optionFilterProp="label"
|
|
@@ -514,14 +579,14 @@ function AdminResources({
|
|
|
}))}
|
|
}))}
|
|
|
/>
|
|
/>
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
|
|
|
<Select options={targetOptions} />
|
|
<Select options={targetOptions} />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
|
|
|
|
|
|
|
+ <Button htmlType="submit" loading={actionKeys.has(`transfer-dns:${selectedCredentialId}`)}>{tr(locale, '执行迁移', 'Transfer')}</Button>
|
|
|
</Form>
|
|
</Form>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
- <SectionCard title="迁移 API Token">
|
|
|
|
|
- <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading}>
|
|
|
|
|
|
|
+ <SectionCard title={tr(locale, '迁移 API Token', 'Transfer API tokens')}>
|
|
|
|
|
+ <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading || actionKeys.has('transfer-token')}>
|
|
|
<Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
|
|
<Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
|
|
|
<Select
|
|
<Select
|
|
|
mode="multiple"
|
|
mode="multiple"
|
|
@@ -532,32 +597,33 @@ function AdminResources({
|
|
|
}))}
|
|
}))}
|
|
|
/>
|
|
/>
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
|
|
|
<Select options={targetOptions} />
|
|
<Select options={targetOptions} />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
|
|
|
|
|
|
|
+ <Button htmlType="submit" loading={actionKeys.has('transfer-token')}>{tr(locale, '执行迁移', 'Transfer')}</Button>
|
|
|
</Form>
|
|
</Form>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
</div>
|
|
</div>
|
|
|
- <SectionCard title="资源归属">
|
|
|
|
|
|
|
+ <SectionCard title={tr(locale, '资源归属', 'Resource ownership')}>
|
|
|
<Table
|
|
<Table
|
|
|
rowKey={(group) => group.user.id}
|
|
rowKey={(group) => group.user.id}
|
|
|
columns={groupColumns}
|
|
columns={groupColumns}
|
|
|
dataSource={groups}
|
|
dataSource={groups}
|
|
|
loading={loading}
|
|
loading={loading}
|
|
|
- expandable={{ expandedRowRender: renderResourceDetails }}
|
|
|
|
|
|
|
+ expandable={{ expandedRowRender: (group) => <ResourceDetails group={group} /> }}
|
|
|
/>
|
|
/>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
</Space>
|
|
</Space>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
|
|
|
|
|
|
|
+function ResourceDetails({ group }: { group: AdminResourceInventory['users'][number] }) {
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
return (
|
|
return (
|
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
|
<Descriptions size="small" column={2}>
|
|
<Descriptions size="small" column={2}>
|
|
|
- <Descriptions.Item label="邮箱">{group.user.email}</Descriptions.Item>
|
|
|
|
|
- <Descriptions.Item label="角色">{group.user.role}</Descriptions.Item>
|
|
|
|
|
|
|
+ <Descriptions.Item label={tr(locale, '邮箱', 'Email')}>{group.user.email}</Descriptions.Item>
|
|
|
|
|
+ <Descriptions.Item label={tr(locale, '角色', 'Role')}>{group.user.role}</Descriptions.Item>
|
|
|
</Descriptions>
|
|
</Descriptions>
|
|
|
<Table
|
|
<Table
|
|
|
size="small"
|
|
size="small"
|
|
@@ -565,9 +631,9 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
|
|
|
pagination={false}
|
|
pagination={false}
|
|
|
dataSource={group.domains}
|
|
dataSource={group.domains}
|
|
|
columns={[
|
|
columns={[
|
|
|
- { title: '域名', dataIndex: 'domain' },
|
|
|
|
|
- { title: '发信主机', dataIndex: 'senderHost' },
|
|
|
|
|
- { title: 'DNS 凭据 ID', dataIndex: 'dnsCredentialId' }
|
|
|
|
|
|
|
+ { title: tr(locale, '域名', 'Domain'), dataIndex: 'domain' },
|
|
|
|
|
+ { title: tr(locale, '发信主机', 'Sending host'), dataIndex: 'senderHost' },
|
|
|
|
|
+ { title: tr(locale, 'DNS 凭据 ID', 'DNS credential ID'), dataIndex: 'dnsCredentialId' }
|
|
|
]}
|
|
]}
|
|
|
/>
|
|
/>
|
|
|
<Table
|
|
<Table
|
|
@@ -576,7 +642,7 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
|
|
|
pagination={false}
|
|
pagination={false}
|
|
|
dataSource={group.dnsCredentials}
|
|
dataSource={group.dnsCredentials}
|
|
|
columns={[
|
|
columns={[
|
|
|
- { title: 'DNS 凭据', dataIndex: 'name' },
|
|
|
|
|
|
|
+ { title: tr(locale, 'DNS 凭据', 'DNS credential'), dataIndex: 'name' },
|
|
|
{ title: 'Provider', dataIndex: 'provider' },
|
|
{ title: 'Provider', dataIndex: 'provider' },
|
|
|
{ title: 'Zone', dataIndex: 'zoneName' }
|
|
{ title: 'Zone', dataIndex: 'zoneName' }
|
|
|
]}
|
|
]}
|
|
@@ -588,8 +654,8 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
|
|
|
dataSource={group.apiTokens}
|
|
dataSource={group.apiTokens}
|
|
|
columns={[
|
|
columns={[
|
|
|
{ title: 'API Token', dataIndex: 'name' },
|
|
{ title: 'API Token', dataIndex: 'name' },
|
|
|
- { title: '前缀', dataIndex: 'tokenPrefix' },
|
|
|
|
|
- { title: '创建时间', dataIndex: 'createdAt', render: formatDate }
|
|
|
|
|
|
|
+ { title: tr(locale, '前缀', 'Prefix'), dataIndex: 'tokenPrefix' },
|
|
|
|
|
+ { title: tr(locale, '创建时间', 'Created at'), dataIndex: 'createdAt', render: formatDate }
|
|
|
]}
|
|
]}
|
|
|
/>
|
|
/>
|
|
|
</Space>
|
|
</Space>
|
|
@@ -599,11 +665,13 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
|
|
|
function AdminMigration({
|
|
function AdminMigration({
|
|
|
users,
|
|
users,
|
|
|
loading,
|
|
loading,
|
|
|
|
|
+ actionKeys,
|
|
|
onPreview,
|
|
onPreview,
|
|
|
onExecute
|
|
onExecute
|
|
|
}: {
|
|
}: {
|
|
|
users: AdminUser[];
|
|
users: AdminUser[];
|
|
|
loading: boolean;
|
|
loading: boolean;
|
|
|
|
|
+ actionKeys: ReadonlySet<string>;
|
|
|
onPreview: (values: { sourceUserId: number; targetUserId: number }) => Promise<{ preview: UserMergePreview }>;
|
|
onPreview: (values: { sourceUserId: number; targetUserId: number }) => Promise<{ preview: UserMergePreview }>;
|
|
|
onExecute: (values: {
|
|
onExecute: (values: {
|
|
|
sourceUserId: number;
|
|
sourceUserId: number;
|
|
@@ -613,20 +681,25 @@ function AdminMigration({
|
|
|
}) => Promise<void>;
|
|
}) => Promise<void>;
|
|
|
}) {
|
|
}) {
|
|
|
const { message } = AntApp.useApp();
|
|
const { message } = AntApp.useApp();
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
const [form] = Form.useForm<{ sourceUserId: number; targetUserId: number }>();
|
|
const [form] = Form.useForm<{ sourceUserId: number; targetUserId: number }>();
|
|
|
const [preview, setPreview] = useState<UserMergePreview | null>(null);
|
|
const [preview, setPreview] = useState<UserMergePreview | null>(null);
|
|
|
const [options, setOptions] = useState<Partial<UserMergeOptions>>({});
|
|
const [options, setOptions] = useState<Partial<UserMergeOptions>>({});
|
|
|
const [confirmation, setConfirmation] = useState('');
|
|
const [confirmation, setConfirmation] = useState('');
|
|
|
|
|
+ const [previewLoading, setPreviewLoading] = useState(false);
|
|
|
const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
|
|
const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
|
|
|
|
|
|
|
|
async function submitPreview(values: { sourceUserId: number; targetUserId: number }) {
|
|
async function submitPreview(values: { sourceUserId: number; targetUserId: number }) {
|
|
|
|
|
+ setPreviewLoading(true);
|
|
|
try {
|
|
try {
|
|
|
const result = await onPreview(values);
|
|
const result = await onPreview(values);
|
|
|
setPreview(result.preview);
|
|
setPreview(result.preview);
|
|
|
setOptions(result.preview.defaultOptions);
|
|
setOptions(result.preview.defaultOptions);
|
|
|
setConfirmation('');
|
|
setConfirmation('');
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- message.error(error instanceof Error ? error.message : '预览失败');
|
|
|
|
|
|
|
+ message.error(error instanceof Error ? error.message : tr(locale, '预览失败', 'Preview failed'));
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setPreviewLoading(false);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -649,15 +722,15 @@ function AdminMigration({
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
|
- <SectionCard title="合并预览">
|
|
|
|
|
|
|
+ <SectionCard title={tr(locale, '合并预览', 'Merge preview')}>
|
|
|
<Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
|
|
<Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
|
|
|
- <Form.Item name="sourceUserId" label="源用户" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <Form.Item name="sourceUserId" label={tr(locale, '源用户', 'Source user')} rules={[{ required: true }]}>
|
|
|
<Select options={userOptions} className="toolbar-select" />
|
|
<Select options={userOptions} className="toolbar-select" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
|
|
|
|
|
|
|
+ <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
|
|
|
<Select options={userOptions} className="toolbar-select" />
|
|
<Select options={userOptions} className="toolbar-select" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Button type="primary" htmlType="submit" loading={loading}>预览</Button>
|
|
|
|
|
|
|
+ <Button type={preview ? 'default' : 'primary'} htmlType="submit" loading={previewLoading}>{tr(locale, '预览', 'Preview')}</Button>
|
|
|
</Form>
|
|
</Form>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
{preview ? (
|
|
{preview ? (
|
|
@@ -668,22 +741,22 @@ function AdminMigration({
|
|
|
) : null}
|
|
) : null}
|
|
|
<Space wrap>
|
|
<Space wrap>
|
|
|
{mergePreviewSummary(preview).map((item) => (
|
|
{mergePreviewSummary(preview).map((item) => (
|
|
|
- <StatusPill key={item.key} tone="neutral">{item.label}: {item.count}</StatusPill>
|
|
|
|
|
|
|
+ <StatusPill key={item.key} tone="neutral">{resourceLabel(item.key, locale)}: {item.count}</StatusPill>
|
|
|
))}
|
|
))}
|
|
|
</Space>
|
|
</Space>
|
|
|
<div className="form-grid two">
|
|
<div className="form-grid two">
|
|
|
- {mergeOptionLabels.map(([key, label]) => (
|
|
|
|
|
|
|
+ {mergeOptionLabels.map(([key, zhLabel, enLabel]) => (
|
|
|
<Checkbox
|
|
<Checkbox
|
|
|
key={key}
|
|
key={key}
|
|
|
checked={options[key] !== false}
|
|
checked={options[key] !== false}
|
|
|
onChange={(event) => setOptions((current) => ({ ...current, [key]: event.target.checked }))}
|
|
onChange={(event) => setOptions((current) => ({ ...current, [key]: event.target.checked }))}
|
|
|
>
|
|
>
|
|
|
- {label}
|
|
|
|
|
|
|
+ {tr(locale, zhLabel, enLabel)}
|
|
|
</Checkbox>
|
|
</Checkbox>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
<Descriptions column={1} bordered size="small">
|
|
<Descriptions column={1} bordered size="small">
|
|
|
- <Descriptions.Item label="确认文本">
|
|
|
|
|
|
|
+ <Descriptions.Item label={tr(locale, '确认文本', 'Confirmation text')}>
|
|
|
<Typography.Text code>{expectedConfirmation}</Typography.Text>
|
|
<Typography.Text code>{expectedConfirmation}</Typography.Text>
|
|
|
</Descriptions.Item>
|
|
</Descriptions.Item>
|
|
|
</Descriptions>
|
|
</Descriptions>
|
|
@@ -695,16 +768,16 @@ function AdminMigration({
|
|
|
<Button
|
|
<Button
|
|
|
danger
|
|
danger
|
|
|
type="primary"
|
|
type="primary"
|
|
|
- loading={loading}
|
|
|
|
|
|
|
+ loading={actionKeys.has('merge')}
|
|
|
disabled={confirmation !== expectedConfirmation}
|
|
disabled={confirmation !== expectedConfirmation}
|
|
|
onClick={execute}
|
|
onClick={execute}
|
|
|
>
|
|
>
|
|
|
- 执行合并
|
|
|
|
|
|
|
+ {tr(locale, '执行合并', 'Merge users')}
|
|
|
</Button>
|
|
</Button>
|
|
|
</Space>
|
|
</Space>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
) : (
|
|
) : (
|
|
|
- <EmptyState description="暂无预览" />
|
|
|
|
|
|
|
+ <EmptyState description={tr(locale, '暂无预览', 'No preview yet')} />
|
|
|
)}
|
|
)}
|
|
|
</Space>
|
|
</Space>
|
|
|
);
|
|
);
|
|
@@ -713,14 +786,17 @@ function AdminMigration({
|
|
|
function AdminSystemEmail({
|
|
function AdminSystemEmail({
|
|
|
settings,
|
|
settings,
|
|
|
loading,
|
|
loading,
|
|
|
|
|
+ actionKeys,
|
|
|
onSave,
|
|
onSave,
|
|
|
onTest
|
|
onTest
|
|
|
}: {
|
|
}: {
|
|
|
settings: SystemEmailSettings | null;
|
|
settings: SystemEmailSettings | null;
|
|
|
loading: boolean;
|
|
loading: boolean;
|
|
|
|
|
+ actionKeys: ReadonlySet<string>;
|
|
|
onSave: (values: Partial<SystemEmailSettings>) => Promise<void>;
|
|
onSave: (values: Partial<SystemEmailSettings>) => Promise<void>;
|
|
|
onTest: (to?: string) => Promise<void>;
|
|
onTest: (to?: string) => Promise<void>;
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
const [form] = Form.useForm<SystemEmailSettings>();
|
|
const [form] = Form.useForm<SystemEmailSettings>();
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
@@ -733,7 +809,7 @@ function AdminSystemEmail({
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <SectionCard title="系统邮件服务器">
|
|
|
|
|
|
|
+ <SectionCard title={tr(locale, '系统邮件服务器', 'System email server')}>
|
|
|
<Form form={form} layout="vertical" onFinish={submit} disabled={loading}>
|
|
<Form form={form} layout="vertical" onFinish={submit} disabled={loading}>
|
|
|
<div className="form-grid two">
|
|
<div className="form-grid two">
|
|
|
<Form.Item name="host" label="SMTP Host" rules={[{ required: true }]}>
|
|
<Form.Item name="host" label="SMTP Host" rules={[{ required: true }]}>
|
|
@@ -751,7 +827,7 @@ function AdminSystemEmail({
|
|
|
<Form.Item name="username" label="Username">
|
|
<Form.Item name="username" label="Username">
|
|
|
<Input autoComplete="off" />
|
|
<Input autoComplete="off" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="password" label={settings?.passwordSet ? 'Password(留空保留)' : 'Password'}>
|
|
|
|
|
|
|
+ <Form.Item name="password" label={settings?.passwordSet ? tr(locale, 'Password(留空保留)', 'Password (leave blank to keep)') : 'Password'}>
|
|
|
<Input.Password autoComplete="new-password" />
|
|
<Input.Password autoComplete="new-password" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
<Form.Item name="fromEmail" label="From Email" rules={[{ required: true, type: 'email' }]}>
|
|
<Form.Item name="fromEmail" label="From Email" rules={[{ required: true, type: 'email' }]}>
|
|
@@ -765,8 +841,8 @@ function AdminSystemEmail({
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
</div>
|
|
</div>
|
|
|
<Space wrap>
|
|
<Space wrap>
|
|
|
- <Button type="primary" htmlType="submit" loading={loading}>保存配置</Button>
|
|
|
|
|
- <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={loading}>发送测试</Button>
|
|
|
|
|
|
|
+ <Button type="primary" htmlType="submit" loading={actionKeys.has('system-email:save')}>{tr(locale, '保存配置', 'Save settings')}</Button>
|
|
|
|
|
+ <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={actionKeys.has('system-email:test')}>{tr(locale, '发送测试', 'Send test')}</Button>
|
|
|
</Space>
|
|
</Space>
|
|
|
</Form>
|
|
</Form>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
@@ -776,25 +852,32 @@ function AdminSystemEmail({
|
|
|
function AdminAuditLogs({
|
|
function AdminAuditLogs({
|
|
|
logs,
|
|
logs,
|
|
|
users,
|
|
users,
|
|
|
|
|
+ query,
|
|
|
loading,
|
|
loading,
|
|
|
onSearch
|
|
onSearch
|
|
|
}: {
|
|
}: {
|
|
|
logs: AuditLogEntry[];
|
|
logs: AuditLogEntry[];
|
|
|
users: AdminUser[];
|
|
users: AdminUser[];
|
|
|
|
|
+ query: string;
|
|
|
loading: boolean;
|
|
loading: boolean;
|
|
|
onSearch: (query: string) => Promise<void>;
|
|
onSearch: (query: string) => Promise<void>;
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
const [form] = Form.useForm();
|
|
const [form] = Form.useForm();
|
|
|
const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
|
|
const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ form.setFieldsValue(auditFiltersFromQuery(query));
|
|
|
|
|
+ }, [form, query]);
|
|
|
|
|
+
|
|
|
const columns: ColumnsType<AuditLogEntry> = [
|
|
const columns: ColumnsType<AuditLogEntry> = [
|
|
|
- { title: '时间', dataIndex: 'createdAt', width: 190, render: formatDate },
|
|
|
|
|
- { title: '动作', dataIndex: 'action', width: 220 },
|
|
|
|
|
- { title: '操作者', dataIndex: 'actorUserId', width: 130, render: (value) => value ?? 'system' },
|
|
|
|
|
- { title: '目标用户', dataIndex: 'targetUserId', width: 130, render: (value) => value ?? '-' },
|
|
|
|
|
- { title: '目标', render: (_, log) => `${log.targetType}:${log.targetId || '-'}`, width: 180 },
|
|
|
|
|
|
|
+ { title: tr(locale, '时间', 'Time'), dataIndex: 'createdAt', width: 190, render: formatDate },
|
|
|
|
|
+ { title: tr(locale, '动作', 'Action'), dataIndex: 'action', width: 220 },
|
|
|
|
|
+ { title: tr(locale, '操作者', 'Actor'), dataIndex: 'actorUserId', width: 130, render: (value) => value ?? 'system' },
|
|
|
|
|
+ { title: tr(locale, '目标用户', 'Target user'), dataIndex: 'targetUserId', width: 130, render: (value) => value ?? '-' },
|
|
|
|
|
+ { title: tr(locale, '目标', 'Target'), render: (_, log) => `${log.targetType}:${log.targetId || '-'}`, width: 180 },
|
|
|
{
|
|
{
|
|
|
- title: '摘要',
|
|
|
|
|
|
|
+ title: tr(locale, '摘要', 'Summary'),
|
|
|
dataIndex: 'summary',
|
|
dataIndex: 'summary',
|
|
|
render: (value) => (
|
|
render: (value) => (
|
|
|
<Typography.Text code ellipsis>
|
|
<Typography.Text code ellipsis>
|
|
@@ -812,22 +895,22 @@ function AdminAuditLogs({
|
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
<Space direction="vertical" size={16} className="full-width">
|
|
|
<SectionCard className="admin-audit-toolbar-card">
|
|
<SectionCard className="admin-audit-toolbar-card">
|
|
|
<Form form={form} layout="inline" onFinish={submit} disabled={loading}>
|
|
<Form form={form} layout="inline" onFinish={submit} disabled={loading}>
|
|
|
- <Form.Item name="actorUserId" label="操作者">
|
|
|
|
|
|
|
+ <Form.Item name="actorUserId" label={tr(locale, '操作者', 'Actor')}>
|
|
|
<Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
|
|
<Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="targetUserId" label="目标用户">
|
|
|
|
|
|
|
+ <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')}>
|
|
|
<Select allowClear options={userOptions} className="toolbar-select" />
|
|
<Select allowClear options={userOptions} className="toolbar-select" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="action" label="动作">
|
|
|
|
|
|
|
+ <Form.Item name="action" label={tr(locale, '动作', 'Action')}>
|
|
|
<Input placeholder="admin.user_merge" />
|
|
<Input placeholder="admin.user_merge" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="from" label="开始">
|
|
|
|
|
|
|
+ <Form.Item name="from" label={tr(locale, '开始', 'From')}>
|
|
|
<Input placeholder="2026-07-08" />
|
|
<Input placeholder="2026-07-08" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Form.Item name="to" label="结束">
|
|
|
|
|
|
|
+ <Form.Item name="to" label={tr(locale, '结束', 'To')}>
|
|
|
<Input placeholder="2026-07-09" />
|
|
<Input placeholder="2026-07-09" />
|
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
- <Button type="primary" htmlType="submit" loading={loading}>查询</Button>
|
|
|
|
|
|
|
+ <Button type="primary" htmlType="submit" loading={loading}>{tr(locale, '查询', 'Search')}</Button>
|
|
|
</Form>
|
|
</Form>
|
|
|
</SectionCard>
|
|
</SectionCard>
|
|
|
<Table rowKey="id" columns={columns} dataSource={logs} loading={loading} scroll={{ x: 1100 }} />
|
|
<Table rowKey="id" columns={columns} dataSource={logs} loading={loading} scroll={{ x: 1100 }} />
|
|
@@ -836,15 +919,16 @@ function AdminAuditLogs({
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] }) {
|
|
function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] }) {
|
|
|
- if (!counts) return <Tag>无资源</Tag>;
|
|
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
|
|
+ if (!counts) return <Tag>{tr(locale, '无资源', 'No resources')}</Tag>;
|
|
|
return (
|
|
return (
|
|
|
<Space wrap>
|
|
<Space wrap>
|
|
|
- <Tag>域名 {counts.domains}</Tag>
|
|
|
|
|
|
|
+ <Tag>{tr(locale, '域名', 'Domains')} {counts.domains}</Tag>
|
|
|
<Tag>DNS {counts.dnsCredentials}</Tag>
|
|
<Tag>DNS {counts.dnsCredentials}</Tag>
|
|
|
<Tag>Token {counts.apiTokens}</Tag>
|
|
<Tag>Token {counts.apiTokens}</Tag>
|
|
|
- <Tag>收信 {counts.inboundMailboxes}</Tag>
|
|
|
|
|
- <Tag>入站 {counts.inboundMessages}</Tag>
|
|
|
|
|
- <Tag>记录 {counts.sendEvents}</Tag>
|
|
|
|
|
|
|
+ <Tag>{tr(locale, '收信', 'Mailboxes')} {counts.inboundMailboxes}</Tag>
|
|
|
|
|
+ <Tag>{tr(locale, '入站', 'Inbound')} {counts.inboundMessages}</Tag>
|
|
|
|
|
+ <Tag>{tr(locale, '记录', 'Events')} {counts.sendEvents}</Tag>
|
|
|
<Tag>SMTP {counts.smtpCredential}</Tag>
|
|
<Tag>SMTP {counts.smtpCredential}</Tag>
|
|
|
</Space>
|
|
</Space>
|
|
|
);
|
|
);
|
|
@@ -852,8 +936,9 @@ function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] })
|
|
|
|
|
|
|
|
function UserStatusTag({ status }: { status: UserStatus }) {
|
|
function UserStatusTag({ status }: { status: UserStatus }) {
|
|
|
const meta = adminUserStatusMeta(status);
|
|
const meta = adminUserStatusMeta(status);
|
|
|
|
|
+ const { locale } = useI18n();
|
|
|
const tone = statusToneFromColor(meta.color);
|
|
const tone = statusToneFromColor(meta.color);
|
|
|
- return <StatusPill tone={tone}>{meta.label}</StatusPill>;
|
|
|
|
|
|
|
+ return <StatusPill tone={tone}>{userStatusLabel(status, locale)}</StatusPill>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function statusToneFromColor(color: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
|
|
function statusToneFromColor(color: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
|
|
@@ -867,3 +952,60 @@ function statusToneFromColor(color: string): 'success' | 'warning' | 'error' | '
|
|
|
function formatDate(value?: string) {
|
|
function formatDate(value?: string) {
|
|
|
return value ? new Date(value).toLocaleString() : '-';
|
|
return value ? new Date(value).toLocaleString() : '-';
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+function userMutationPending(actionKeys: ReadonlySet<string>, userId: number) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ `update:${userId}`,
|
|
|
|
|
+ `approve:${userId}`,
|
|
|
|
|
+ `resend:${userId}`,
|
|
|
|
|
+ `reset:${userId}`,
|
|
|
|
|
+ `temporary-password:${userId}`
|
|
|
|
|
+ ].some((key) => actionKeys.has(key));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function auditFiltersFromQuery(query: string) {
|
|
|
|
|
+ const params = new URLSearchParams(query);
|
|
|
|
|
+ const actor = params.get('actorUserId');
|
|
|
|
|
+ const target = params.get('targetUserId');
|
|
|
|
|
+ return {
|
|
|
|
|
+ actorUserId: actor === 'system' ? actor : positiveInteger(actor),
|
|
|
|
|
+ targetUserId: positiveInteger(target),
|
|
|
|
|
+ action: params.get('action') || undefined,
|
|
|
|
|
+ from: params.get('from') || undefined,
|
|
|
|
|
+ to: params.get('to') || undefined
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function positiveInteger(value: string | null) {
|
|
|
|
|
+ const parsed = Number(value);
|
|
|
|
|
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function tr(locale: string, zh: string, en: string) {
|
|
|
|
|
+ return locale.startsWith('en') ? en : zh;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function userStatusLabel(status: UserStatus, locale: string) {
|
|
|
|
|
+ const labels: Record<UserStatus, [string, string]> = {
|
|
|
|
|
+ pending_email: ['待验证邮箱', 'Email verification pending'],
|
|
|
|
|
+ pending_review: ['待管理员审核', 'Admin review pending'],
|
|
|
|
|
+ active: ['正常', 'Active'],
|
|
|
|
|
+ disabled: ['已禁用', 'Disabled']
|
|
|
|
|
+ };
|
|
|
|
|
+ const label = labels[status];
|
|
|
|
|
+ return label ? tr(locale, label[0], label[1]) : status;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resourceLabel(key: string, locale: string) {
|
|
|
|
|
+ const labels: Record<string, [string, string]> = {
|
|
|
|
|
+ domains: ['域名', 'Domains'],
|
|
|
|
|
+ dnsCredentials: ['DNS 凭据', 'DNS credentials'],
|
|
|
|
|
+ apiTokens: ['API Token', 'API tokens'],
|
|
|
|
|
+ inboundMailboxes: ['收信邮箱', 'Mailboxes'],
|
|
|
|
|
+ inboundMessages: ['入站邮件', 'Inbound mail'],
|
|
|
|
|
+ sendEvents: ['发送记录', 'Sending activity'],
|
|
|
|
|
+ smtpCredential: ['SMTP 凭据', 'SMTP credential']
|
|
|
|
|
+ };
|
|
|
|
|
+ const label = labels[key];
|
|
|
|
|
+ return label ? tr(locale, label[0], label[1]) : key;
|
|
|
|
|
+}
|