feat: strengthen risk controls and review workflows

This commit is contained in:
hectorzhao
2026-07-26 13:08:12 +08:00
parent b461532075
commit 2ce682c3fc
34 changed files with 2167 additions and 390 deletions
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
import { adminApi, type ApplicationCmppParams, type ApplicationDeactivationPreview, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
import { copyText } from '@/utils/clipboard';
@@ -14,7 +14,9 @@ type SmsApp = {
name: string;
enterprise: string;
appId: string;
status: string;
enabled: boolean;
deactivation?: ApplicationDeactivationPreview | null;
sentToday: number;
deliveryRate: number;
unitPrice: number;
@@ -51,8 +53,22 @@ type CmppConnection = {
pendingWindow: number;
};
function enabledTag(enabled: boolean) {
return <Tag tone={enabled ? 'success' : 'neutral'}>{enabled ? '启用' : '停用'}</Tag>;
function applicationStatusTag(app: SmsApp) {
if (app.status === 'disabling') {
const detail = app.deactivation;
const title = [
detail?.reason || '等待未完成回执清算',
`等待供应商回执:${detail?.awaitingSupplierReceipt ?? 0}`,
`等待推送:${detail?.waitingToSend ?? 0}`,
`等待客户端确认:${detail?.awaitingClientAck ?? 0}`,
`可重试失败:${detail?.retryableFailures ?? 0}`,
`待推送上行:${detail?.pendingUplinks ?? 0}`,
`进入停用中:${formatDateTime(detail?.disablingAt)}`,
`自动停用时间:${formatDateTime(detail?.autoDisableAt)}`,
].join('\n');
return <span aria-label={title} className="application-status-detail" tabIndex={0} title={title}><Tag tone="warning"></Tag></span>;
}
return <Tag tone={app.status === 'active' ? 'success' : 'neutral'}>{app.status === 'active' ? '启用' : '停用'}</Tag>;
}
function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: string; danger?: boolean; onCancel: () => void; onConfirm: () => void }) {
@@ -73,6 +89,51 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin
);
}
function DeactivateApplicationModal({
app,
preview,
onCancel,
onConfirm,
}: {
app: SmsApp;
preview: ApplicationDeactivationPreview;
onCancel: () => void;
onConfirm: (mode: 'wait' | 'force') => void;
}) {
const hasOutstanding = preview.totalOutstanding > 0;
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
{hasOutstanding ? <Button onClick={() => onConfirm('force')} variant="danger"></Button> : null}
<Button onClick={() => onConfirm(hasOutstanding ? 'wait' : 'force')} variant={hasOutstanding ? 'warning' : 'primary'}>
{hasOutstanding ? '等待回执后停用' : '确认停用'}
</Button>
</>
)}
onClose={onCancel}
open
title={`停用应用“${app.name}`}
>
{hasOutstanding ? (
<div className="section-stack">
<p className="admin-confirm-text"> {preview.totalOutstanding} </p>
<div className="cmpp-connection-summary">
<div><span></span><strong>{preview.awaitingSupplierReceipt}</strong></div>
<div><span></span><strong>{preview.waitingToSend}</strong></div>
<div><span></span><strong>{preview.awaitingClientAck}</strong></div>
<div><span></span><strong>{preview.retryableFailures}</strong></div>
<div><span></span><strong>{preview.pendingUplinks}</strong></div>
<div><span>CMPP连接</span><strong>{preview.activeConnections}</strong></div>
</div>
<p className="form-hint">72</p>
</div>
) : <p className="admin-confirm-text"> CMPP </p>}
</Modal>
);
}
function AddApplicationModal({
tenants,
loading,
@@ -272,10 +333,11 @@ export function AdminEnterpriseApplicationsPage() {
const [tenantsLoading, setTenantsLoading] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [confirmAction, setConfirmAction] = useState<
| { action: 'toggle'; id: string; name: string; enabled: boolean }
| { action: 'enable'; id: string; name: string }
| { action: 'delete'; id: string; name: string }
| null
>(null);
const [deactivateAction, setDeactivateAction] = useState<{ app: SmsApp; preview: ApplicationDeactivationPreview } | null>(null);
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) {
try {
@@ -312,11 +374,33 @@ export function AdminEnterpriseApplicationsPage() {
}
}
async function confirmToggle(id: string) {
const app = smsApps.find((item) => item.id === id);
if (app) {
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
async function confirmEnable(id: string) {
await adminApi.changeApplicationStatus(id, 'active', '运营端恢复启用企业应用');
await loadSmsApps();
}
async function openDeactivate(app: SmsApp) {
try {
setDeactivateAction({ app, preview: await adminApi.getApplicationDeactivationPreview(app.id) });
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '停用影响检查失败');
}
}
async function confirmDeactivate(mode: 'wait' | 'force') {
if (!deactivateAction) return;
try {
await adminApi.changeApplicationStatus(
deactivateAction.app.id,
mode === 'wait' ? 'disabling' : 'disabled',
mode === 'wait' ? '运营端选择等待回执后停用' : '运营端选择强制停用并放弃剩余回执',
mode === 'force',
);
setDeactivateAction(null);
await loadSmsApps();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业应用停用失败');
}
}
@@ -329,8 +413,8 @@ export function AdminEnterpriseApplicationsPage() {
if (!confirmAction) {
return;
}
if (confirmAction.action === 'toggle') {
await confirmToggle(confirmAction.id);
if (confirmAction.action === 'enable') {
await confirmEnable(confirmAction.id);
} else {
await confirmDelete(confirmAction.id);
}
@@ -360,7 +444,7 @@ export function AdminEnterpriseApplicationsPage() {
const filteredSmsApps = useMemo(
() => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword))
&& (appliedStatus === 'all' || (appliedStatus === 'active' ? item.enabled : !item.enabled))),
&& (appliedStatus === 'all' || item.status === appliedStatus)),
[appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps],
);
@@ -390,7 +474,7 @@ export function AdminEnterpriseApplicationsPage() {
</div>
),
},
{ key: 'enabled', title: '状态', width: '130px', render: (record) => enabledTag(record.enabled) },
{ key: 'enabled', title: '状态', width: '130px', render: (record) => applicationStatusTag(record) },
{
key: 'actions',
title: '操作',
@@ -399,8 +483,8 @@ export function AdminEnterpriseApplicationsPage() {
render: (record) => (
<div className="table-actions enterprise-app-actions">
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
{record.enabled ? '停用' : '启用'}
<Button onClick={() => { if (record.status === 'active') void openDeactivate(record); else setConfirmAction({ action: 'enable', id: record.id, name: record.name }); }} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
{record.status === 'active' ? '停用' : '启用'}
</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger"></Button>
</div>
@@ -436,7 +520,7 @@ export function AdminEnterpriseApplicationsPage() {
<Select
label="状态"
onChange={(event) => setStatus(event.target.value)}
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用', value: 'disabled' }]}
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用中', value: 'disabling' }, { label: '停用', value: 'disabled' }]}
value={status}
/>
<div className="admin-split-filter__actions">
@@ -461,11 +545,19 @@ export function AdminEnterpriseApplicationsPage() {
danger={confirmAction.action === 'delete'}
message={confirmAction.action === 'delete'
? `确认删除应用“${confirmAction.name}”吗?`
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
: `确认启用应用“${confirmAction.name}”吗?`}
onCancel={() => setConfirmAction(null)}
onConfirm={() => { void runConfirmedAction(); }}
/>
) : null}
{deactivateAction ? (
<DeactivateApplicationModal
app={deactivateAction.app}
onCancel={() => setDeactivateAction(null)}
onConfirm={(mode) => { void confirmDeactivate(mode); }}
preview={deactivateAction.preview}
/>
) : null}
{addModalOpen ? (
<AddApplicationModal
loading={tenantsLoading}
@@ -496,7 +588,9 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
name: application.name,
enterprise: application.tenant?.name ?? application.tenantId,
appId: application.id,
status: application.status,
enabled: application.status === 'active',
deactivation: application.deactivation,
sentToday: application.sentToday ?? 0,
deliveryRate: application.deliveryRate ?? 0,
unitPrice: moneyUnitsToYuan(application.customerUnitPrice),
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useMemo, useState } from 'react';
import { Pencil, Plus, RefreshCw } from 'lucide-react';
import {
adminApi,
type EnterpriseApplication,
type RiskRuleItem,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
Input,
Modal,
Select,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [
{ code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' },
{ code: 'NON_WORKING_MARKETING_BULK', label: '非工作时间大批量营销发送', unit: '个号码' },
{ code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
];
type EditorState = {
id?: string;
applicationId: string;
code: RiskRuleItem['code'];
thresholdValue: string;
action: RiskRuleItem['action'];
status: RiskRuleItem['status'];
priority: string;
startTime: string;
endTime: string;
};
function editorFromRule(rule?: RiskRuleItem): EditorState {
return {
id: rule?.id,
applicationId: rule?.applicationId ?? '',
code: rule?.code ?? 'MAX_PHONES_PER_TASK',
thresholdValue: String(rule?.thresholdValue ?? 100000),
action: rule?.action ?? 'block',
status: rule?.status ?? 'active',
priority: String(rule?.priority ?? 100),
startTime: rule?.config?.startTime ?? '21:00',
endTime: rule?.config?.endTime ?? '08:00',
};
}
export function AdminRiskRulesPage() {
const [rules, setRules] = useState<RiskRuleItem[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [applicationId, setApplicationId] = useState('');
const [editor, setEditor] = useState<EditorState | null>(null);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
function load() {
Promise.all([
adminApi.listRiskRules(applicationId || undefined),
applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications),
]).then(([nextRules, nextApplications]) => {
setRules(nextRules);
setApplications(nextApplications);
setError('');
}).catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
}
useEffect(load, [applicationId]);
const existingCodes = useMemo(
() => new Set(rules.filter((rule) => rule.applicationId === editor?.applicationId).map((rule) => rule.code)),
[editor?.applicationId, rules],
);
async function save() {
if (!editor) return;
if (!editor.id && !editor.applicationId) {
setError('请选择企业应用');
return;
}
const thresholdValue = Number(editor.thresholdValue);
if (!Number.isFinite(thresholdValue) || thresholdValue < 0) {
setError('阈值必须是大于等于0的数字');
return;
}
setSaving(true);
setError('');
const body = {
thresholdValue,
action: editor.action,
status: editor.status,
priority: Number(editor.priority) || 100,
config: editor.code === 'NON_WORKING_MARKETING_BULK'
? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' }
: undefined,
};
try {
if (editor.id) {
await adminApi.updateRiskRule(editor.id, body);
} else {
await adminApi.createRiskRule({
...body,
applicationId: editor.applicationId || undefined,
code: editor.code,
});
}
setEditor(null);
load();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '风控规则保存失败');
} finally {
setSaving(false);
}
}
const columns: Array<TableColumn<RiskRuleItem>> = [
{ key: 'name', title: '规则名称', render: (rule) => <div><strong>{rule.name}</strong><small className="table-subline">{rule.description}</small></div> },
{ key: 'scope', title: '适用范围', render: (rule) => rule.application ? <div><strong>{rule.application.name}</strong><small className="table-subline">{rule.application.tenant?.name ?? '-'}</small></div> : <Tag tone="info"></Tag> },
{ key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` },
{ key: 'time', title: '生效时间', width: '180px', render: (rule) => {
if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-';
const start = rule.config?.startTime ?? '21:00';
const end = rule.config?.endTime ?? '08:00';
return `${start}${start > end ? '次日' : ''}${end}`;
} },
{ key: 'action', title: '处理动作', width: '120px', render: (rule) => <Tag tone={rule.action === 'block' ? 'danger' : 'warning'}>{rule.action === 'block' ? '直接拒绝' : '人工审核'}</Tag> },
{ key: 'status', title: '状态', width: '100px', render: (rule) => <Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag> },
{ key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority },
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost"></Button> },
];
return (
<section className="page-stack">
<div className="page-heading">
<div><Breadcrumb items={['风控管理', '风控规则']} /><h1></h1><p></p></div>
<div className="page-heading__actions">
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost"></Button>
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}></Button>
</div>
</div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<div className="surface sms-audit-filter">
<Select
label="查看范围"
onChange={(event) => setApplicationId(event.target.value)}
options={[
{ label: '全部全局规则', value: '' },
...applications.map((application) => ({
label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`,
value: application.id,
})),
]}
value={applicationId}
/>
</div>
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
{editor ? <Modal
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost"></Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>}
onClose={() => setEditor(null)}
open
size="xl"
title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'}
>
<div className="form-grid">
{!editor.id ? <Select
label="企业应用"
onChange={(event) => setEditor({ ...editor, applicationId: event.target.value })}
options={[{ label: '请选择企业应用', value: '' }, ...applications.map((application) => ({ label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`, value: application.id }))]}
value={editor.applicationId}
/> : null}
{!editor.id ? <Select
label="规则"
onChange={(event) => {
const code = event.target.value as RiskRuleItem['code'];
const globalRule = rules.find((rule) => !rule.applicationId && rule.code === code);
setEditor({ ...editor, code, thresholdValue: String(globalRule?.thresholdValue ?? editor.thresholdValue), action: globalRule?.action ?? editor.action });
}}
options={definitions.filter((item) => !existingCodes.has(item.code) || item.code === editor.code).map((item) => ({ label: item.label, value: item.code }))}
value={editor.code}
/> : <Input disabled label="规则" value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code} />}
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''}`} min="0" onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
<Select label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={[{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={editor.action} />
<Select label="状态" onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} />
<Input label="优先级" min="1" onChange={(event) => setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} />
{editor.code === 'NON_WORKING_MARKETING_BULK' ? <>
<Input label="非工作时间开始" onChange={(event) => setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} />
<Input label="非工作时间结束" onChange={(event) => setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} />
</> : null}
</div>
</Modal> : null}
</section>
);
}
@@ -41,7 +41,6 @@ export function AdminSmsApplicationFormPage() {
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
@@ -82,7 +81,6 @@ export function AdminSmsApplicationFormPage() {
setInterfaceEnabled(true);
setInterfaceType('cmpp20');
setCmppMaxConnections('1');
setPhoneDailyLimit('10000');
setMismatchPolicy('manual_review');
setDownstreamReceiptRetryEnabled(true);
setDownstreamUplinkRetryEnabled(true);
@@ -156,7 +154,6 @@ export function AdminSmsApplicationFormPage() {
setInterfaceEnabled(application.interfaceEnabled !== false);
setInterfaceType('cmpp20');
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setDownstreamReceiptRetryEnabled(application.downstreamReceiptRetryEnabled !== false);
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
@@ -223,7 +220,6 @@ export function AdminSmsApplicationFormPage() {
interfaceEnabled,
interfaceType,
cmppMaxConnections: Number(cmppMaxConnections) || 1,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
downstreamReceiptRetryEnabled,
downstreamUplinkRetryEnabled,
@@ -305,7 +301,6 @@ export function AdminSmsApplicationFormPage() {
<span></span>
</div>
</div>
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10000" required value={phoneDailyLimit} />
<Select
label="不符合模板的短信"
onChange={(event) => setMismatchPolicy(event.target.value)}
+71 -9
View File
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, Info, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabel: Record<string, string> = {
pending_review: '待审核',
approved: '通过',
rejected: '驳回',
approved: '人工通过',
rejected: '人工驳回',
};
const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
@@ -20,6 +20,20 @@ function sourceLabel(sourceType?: string) {
return sourceType === 'cmpp_template_mismatch' ? 'CMPP模板不匹配聚合' : '风控审核';
}
function messageStatusLabel(status: string) {
return {
pending_review: '待人工审核',
queued: '已入队',
scheduled: '等待定时发送',
submitted: '供应商已受理',
delivered: '送达成功',
submit_failed: '提交失败',
failed: '回执失败',
rejected: '已拒绝',
timeout: '超时',
}[status] ?? status;
}
export function AdminSmsAuditPage() {
const [records, setRecords] = useState<RiskReviewTask[]>([]);
const [keyword, setKeyword] = useState('');
@@ -31,6 +45,11 @@ export function AdminSmsAuditPage() {
const [rejectReason, setRejectReason] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailTarget, setDetailTarget] = useState<RiskReviewTask | null>(null);
const [phoneTarget, setPhoneTarget] = useState<RiskReviewTask | null>(null);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneData, setPhoneData] = useState<RiskTaskMessagePage>({ items: [], total: 0, page: 1, pageSize: 20 });
function refreshAuditCount() {
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
@@ -53,12 +72,24 @@ export function AdminSmsAuditPage() {
const filteredRecords = useMemo(
() => records.filter((record) => {
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
const matchesDate = !date || record.createdAt.startsWith(date);
const relevantDate = record.status === 'pending_review' ? record.createdAt : record.reviewedAt ?? record.createdAt;
const matchesDate = !date || relevantDate.startsWith(date);
return matchesKeyword && matchesDate;
}),
[date, keyword, records],
);
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
if (!target) return;
adminApi.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
.then(setPhoneData)
.catch((failure: Error) => setError(failure.message || '审核号码列表加载失败'));
}
useEffect(() => {
if (phoneTarget) loadPhones(phoneTarget, phonePage, phonePageSize);
}, [phoneTarget, phonePage, phonePageSize]);
async function approveRecord(record: RiskReviewTask) {
await adminApi.approveRiskReviewTask(record.id, '运营审核通过');
setApproveTarget(null);
@@ -104,7 +135,7 @@ export function AdminSmsAuditPage() {
},
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
{ key: 'phoneTotal', title: '号码数', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · </button> },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join('') ?? '-' },
{
@@ -143,13 +174,13 @@ export function AdminSmsAuditPage() {
options={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '通过', value: 'approved' },
{ label: '驳回', value: 'rejected' },
{ label: '人工通过', value: 'approved' },
{ label: '人工驳回', value: 'rejected' },
]}
value={status}
/>
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<Input label={status === 'pending_review' ? '提交日期' : '审核日期'} onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDate(''); setStatus('pending_review'); }} variant="ghost"></Button>
@@ -191,6 +222,37 @@ export function AdminSmsAuditPage() {
</div>
</Modal> : null}
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}></Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · ${phoneTarget.taskNo}`}>
<div className="page-stack">
<div className="audit-filter-grid">
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div>
</div>
<Table
columns={[
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
{ key: 'carrier', title: '运营商', render: (item) => item.carrier || '-' },
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'pending_review' ? 'warning' : item.status === 'failed' || item.status === 'submit_failed' ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
]}
data={phoneData.items}
emptyText="暂无号码记录"
rowKey="id"
/>
<Pagination
nextDisabled={phonePage * phonePageSize >= phoneData.total}
onNext={() => setPhonePage((current) => current + 1)}
onPageChange={setPhonePage}
onPrevious={() => setPhonePage((current) => Math.max(1, current - 1))}
page={phonePage}
previousDisabled={phonePage <= 1}
total={phoneData.total}
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
/>
</div>
</Modal> : null}
<Modal
footer={(
<>
+112 -30
View File
@@ -21,6 +21,14 @@ type ConfirmAction = {
user: ManagedUser;
};
type UserFilters = {
displayName: string;
login: string;
tenantId: string;
roleCode: string;
status: string;
};
const emptyForm: UserForm = {
tenantId: '',
displayName: '',
@@ -37,6 +45,14 @@ const roleLabel: Record<string, string> = {
enterprise_admin: '企业管理员',
};
const emptyFilters: UserFilters = {
displayName: '',
login: '',
tenantId: '',
roleCode: '',
status: '',
};
function toForm(user?: ManagedUser): UserForm {
const roleCode = user?.roles[0]?.role.code === 'enterprise_admin' ? 'enterprise_admin' : 'platform_admin';
return user ? {
@@ -62,8 +78,8 @@ export function AdminUsersPage() {
const session = readSession('admin');
const [users, setUsers] = useState<ManagedUser[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<UserForm>(emptyForm);
@@ -71,35 +87,63 @@ export function AdminUsersPage() {
const [newPassword, setNewPassword] = useState('');
const [showInitialPassword, setShowInitialPassword] = useState(false);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [confirmError, setConfirmError] = useState('');
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState('');
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
const [querying, setQuerying] = useState(false);
async function load() {
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
setUsers(nextUsers);
setTenants(nextTenants);
async function loadUsers(query: UserFilters = appliedFilters) {
setUsers(await adminApi.listUsers(query));
}
useEffect(() => {
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
void Promise.all([adminApi.listUsers(), adminApi.listTenants()])
.then(([nextUsers, nextTenants]) => {
setUsers(nextUsers);
setTenants(nextTenants);
})
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
}, []);
const filteredUsers = useMemo(() => {
const value = appliedKeyword.trim().toLowerCase();
return users.filter((user) => {
const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
return !value || target.includes(value);
});
}, [appliedKeyword, users]);
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
}
async function queryUsers(nextFilters = filters) {
const next = {
...nextFilters,
displayName: nextFilters.displayName.trim(),
login: nextFilters.login.trim(),
};
setQuerying(true);
setError('');
try {
await loadUsers(next);
setAppliedFilters(next);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '查询用户失败');
} finally {
setQuerying(false);
}
}
function openConfirm(action: ConfirmAction) {
setConfirmError('');
setConfirmAction(action);
}
function openCreate() {
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
setFormError('');
setShowInitialPassword(false);
setCreating(true);
}
function openEdit(user: ManagedUser) {
setForm(toForm(user));
setFormError('');
setEditingUser(user);
}
@@ -115,11 +159,11 @@ export function AdminUsersPage() {
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6) || (form.roleCode === 'enterprise_admin' && !form.tenantId)) {
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业');
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业');
return;
}
setSaving(true);
setError('');
setFormError('');
const body: UserPayload = {
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
username: form.username || form.email || form.phone,
@@ -138,9 +182,9 @@ export function AdminUsersPage() {
}
setCreating(false);
setEditingUser(null);
await load();
await loadUsers();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户保存失败');
setFormError(failure instanceof Error ? failure.message : '用户保存失败');
} finally {
setSaving(false);
}
@@ -148,13 +192,28 @@ export function AdminUsersPage() {
async function runConfirm() {
if (!confirmAction) return;
if (confirmAction.type === 'delete') {
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
} else {
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
setConfirming(true);
setConfirmError('');
try {
if (confirmAction.type === 'delete') {
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
} else {
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败';
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
setConfirming(false);
return;
}
setConfirmAction(null);
await load();
try {
await loadUsers();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户列表刷新失败');
} finally {
setConfirming(false);
}
}
async function savePassword() {
@@ -180,10 +239,10 @@ export function AdminUsersPage() {
<div className="admin-system-actions">
<Button onClick={() => openEdit(record)} size="sm" variant="ghost"></Button>
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
<Button onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div>
),
},
@@ -199,16 +258,37 @@ export function AdminUsersPage() {
</div>
<div className="surface admin-system-toolbar admin-user-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
<div className="admin-user-filter-grid">
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
<Select
label="所属企业"
onChange={(event) => updateFilter('tenantId', event.target.value)}
options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]}
value={filters.tenantId}
/>
<Select
label="用户角色"
onChange={(event) => updateFilter('roleCode', event.target.value)}
options={[{ label: '全部角色', value: '' }, { label: '平台管理员', value: 'platform_admin' }, { label: '企业管理员', value: 'enterprise_admin' }]}
value={filters.roleCode}
/>
<Select
label="状态"
onChange={(event) => updateFilter('status', event.target.value)}
options={[{ label: '全部状态', value: '' }, { label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]}
value={filters.status}
/>
</div>
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedKeyword(keyword.trim())}></Button>
<Button onClick={() => { setKeyword(''); setAppliedKeyword(''); }} variant="ghost"></Button>
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={16} />} onClick={openCreate} size="sm"></Button>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface admin-system-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
</div>
{(creating || editingUser) ? (
@@ -263,6 +343,7 @@ export function AdminUsersPage() {
<label><input checked={form.status === 'disabled'} onChange={() => updateField('status', 'disabled')} type="radio" /></label>
</div>
</div>
{formError ? <p className="form-error admin-app-form-row--wide" role="alert">{formError}</p> : null}
</div>
</Modal>
) : null}
@@ -276,8 +357,9 @@ export function AdminUsersPage() {
) : null}
{confirmAction ? (
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="ghost"></Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}></Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="ghost"></Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p>
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
</Modal>
) : null}
</section>
+27
View File
@@ -1,4 +1,31 @@
.client-user-filter {
align-items: end;
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(180px, 1fr)) auto;
max-width: none;
}
.client-user-filter__actions {
display: flex;
gap: var(--space-3);
}
@media (max-width: 780px) {
.client-user-filter {
grid-template-columns: 1fr;
}
.client-user-filter__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.client-user-filter__actions .ui-button {
justify-content: center;
width: 100%;
}
.client-users-table-card .client-user-actions {
display: grid;
gap: var(--space-2);
+94 -25
View File
@@ -20,6 +20,12 @@ type ConfirmAction = {
user: ManagedUser;
};
type UserFilters = {
displayName: string;
login: string;
status: string;
};
const emptyForm: UserForm = {
displayName: '',
username: '',
@@ -29,6 +35,12 @@ const emptyForm: UserForm = {
password: '',
};
const emptyFilters: UserFilters = {
displayName: '',
login: '',
status: '',
};
function toForm(user?: ManagedUser): UserForm {
return user ? {
displayName: user.displayName,
@@ -44,35 +56,63 @@ export function ClientUsersPage() {
const session = readSession('client');
const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState<ManagedUser[]>([]);
const [keyword, setKeyword] = useState('');
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<UserForm>(emptyForm);
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [confirmError, setConfirmError] = useState('');
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState('');
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
const [querying, setQuerying] = useState(false);
async function load() {
async function loadUsers(query: UserFilters = appliedFilters) {
if (!tenantId) return;
setUsers(await clientApi.listUsers(tenantId));
setUsers(await clientApi.listUsers(query, tenantId));
}
useEffect(() => {
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
if (!tenantId) return;
void clientApi.listUsers({}, tenantId)
.then(setUsers)
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
}, [tenantId]);
const filteredUsers = useMemo(() => {
const value = keyword.trim().toLowerCase();
return users.filter((item) => {
const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase();
return !value || target.includes(value);
});
}, [keyword, users]);
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
}
async function queryUsers(nextFilters = filters) {
const next = {
...nextFilters,
displayName: nextFilters.displayName.trim(),
login: nextFilters.login.trim(),
};
setQuerying(true);
setError('');
try {
await loadUsers(next);
setAppliedFilters(next);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '查询用户失败');
} finally {
setQuerying(false);
}
}
function openConfirm(action: ConfirmAction) {
setConfirmError('');
setConfirmAction(action);
}
function openEditor(user?: ManagedUser) {
setForm(toForm(user));
setFormError('');
setEditingUser(user ?? null);
setCreating(!user);
}
@@ -83,10 +123,11 @@ export function ClientUsersPage() {
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return;
}
setSaving(true);
setFormError('');
const body: UserPayload = {
displayName: form.displayName,
username: form.username || form.email || form.phone,
@@ -104,9 +145,9 @@ export function ClientUsersPage() {
}
setCreating(false);
setEditingUser(null);
await load();
await loadUsers();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户保存失败');
setFormError(failure instanceof Error ? failure.message : '用户保存失败');
} finally {
setSaving(false);
}
@@ -114,13 +155,28 @@ export function ClientUsersPage() {
async function runConfirm() {
if (!confirmAction) return;
if (confirmAction.type === 'delete') {
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
} else {
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
setConfirming(true);
setConfirmError('');
try {
if (confirmAction.type === 'delete') {
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
} else {
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败';
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
setConfirming(false);
return;
}
setConfirmAction(null);
await load();
try {
await loadUsers();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户列表刷新失败');
} finally {
setConfirming(false);
}
}
async function savePassword() {
@@ -145,8 +201,8 @@ export function ClientUsersPage() {
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button>
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div>
),
},
@@ -162,12 +218,23 @@ export function ClientUsersPage() {
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm"></Button>
</div>
<div className="system-filter-row">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
<div className="surface system-filter-row client-user-filter">
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
<Select
label="状态"
onChange={(event) => updateFilter('status', event.target.value)}
options={[{ label: '全部状态', value: '' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
value={filters.status}
/>
<div className="client-user-filter__actions">
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="secondary"></Button>
</div>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface system-table-card client-users-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
</div>
{(creating || editingUser) ? (
@@ -185,6 +252,7 @@ export function ClientUsersPage() {
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
{formError ? <p className="form-error" role="alert">{formError}</p> : null}
</div>
</Modal>
) : null}
@@ -198,8 +266,9 @@ export function ClientUsersPage() {
) : null}
{confirmAction ? (
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="secondary"></Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}></Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary"></Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p>
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
</Modal>
) : null}
</section>