feat: 按企业应用累计夜间短信并复用聚合审核
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-07 22:55:29 +08:00
parent e281ff853b
commit 633ba59775
18 changed files with 4279 additions and 1767 deletions
+585 -151
View File
@@ -23,7 +23,7 @@ import { formatDateTime } from '@/utils/dateTime';
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: 'NON_WORKING_MARKETING_BULK', label: '夜间累计发送量审核', unit: '条业务短信/应用/夜间' },
{ code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
{ code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' },
{ code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' },
@@ -96,27 +96,32 @@ export function AdminRiskRulesPage() {
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 || '风控规则加载失败'));
])
.then(([nextRules, nextApplications]) => {
setRules(nextRules);
setApplications(nextApplications);
setError('');
})
.catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
}
useEffect(load, [applicationId]);
function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) {
adminApi.listPhoneFrequencyHits({
applicationId: applicationId || undefined,
phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined,
page,
pageSize: 20,
}).then((result) => {
setFrequencyHits(result.items);
setHitTotal(result.total);
setHitPage(result.page);
}).catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
adminApi
.listPhoneFrequencyHits({
applicationId: applicationId || undefined,
phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined,
page,
pageSize: 20,
})
.then((result) => {
setFrequencyHits(result.items);
setHitTotal(result.total);
setHitPage(result.page);
})
.catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
}
useEffect(() => {
@@ -125,16 +130,19 @@ export function AdminRiskRulesPage() {
}, [applicationId]);
function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) {
adminApi.listPhoneFrequencyWhitelist({
phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined,
page,
pageSize: 20,
}).then((result) => {
setWhitelist(result.items);
setWhitelistTotal(result.total);
setWhitelistPage(result.page);
}).catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
adminApi
.listPhoneFrequencyWhitelist({
phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined,
page,
pageSize: 20,
})
.then((result) => {
setWhitelist(result.items);
setWhitelistTotal(result.total);
setWhitelistPage(result.page);
})
.catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
}
useEffect(() => {
@@ -162,12 +170,17 @@ export function AdminRiskRulesPage() {
const phoneFrequencyRule = isPhoneFrequencyRule(editor.code);
const body = {
thresholdValue,
action: phoneFrequencyRule ? 'block' as const : editor.action,
action: phoneFrequencyRule
? ('block' as const)
: editor.code === 'NON_WORKING_MARKETING_BULK'
? ('manual_review' as const)
: 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,
config:
editor.code === 'NON_WORKING_MARKETING_BULK'
? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' }
: undefined,
};
try {
if (editor.id) {
@@ -261,55 +274,227 @@ export function AdminRiskRulesPage() {
}
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: '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';
const pending =
rule.config?.timeConfigEffectiveAt && new Date(rule.config.timeConfigEffectiveAt).getTime() > Date.now();
if (pending && rule.config?.previousTimeConfig) {
const previous = rule.config.previousTimeConfig;
return (
<span>
{previous.startTime}{previous.startTime > previous.endTime ? '次日' : ''}
{previous.endTime}{formatDateTime(rule.config.timeConfigEffectiveAt!)}使 {start}{end}
</span>
);
}
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> },
{
key: 'actions',
title: '操作',
width: '100px',
align: 'right',
render: (rule) => (
<Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">
</Button>
),
},
];
const hitColumns: Array<TableColumn<PhoneFrequencyHit>> = [
{ key: 'phone', title: '号码', width: '140px', render: (hit) => <strong>{hit.phoneNumber}</strong> },
{ key: 'scope', title: '企业 / 应用', render: (hit) => <div><strong>{hit.tenant.name}</strong><small className="table-subline">{hit.application.name}</small></div> },
{ key: 'rule', title: '命中规则', render: (hit) => <div><strong>{hit.ruleName}</strong><small className="table-subline"> {hit.thresholdValue} {hit.actualValue} </small></div> },
{ key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)}${formatDateTime(hit.windowEndsAt)}` },
{ key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt
? <Tag tone="neutral"></Tag>
: new Date(hit.windowEndsAt).getTime() <= Date.now()
? <Tag tone="warning"></Tag>
: <Tag tone="danger"></Tag> },
{
key: 'scope',
title: '企业 / 应用',
render: (hit) => (
<div>
<strong>{hit.tenant.name}</strong>
<small className="table-subline">{hit.application.name}</small>
</div>
),
},
{
key: 'rule',
title: '命中规则',
render: (hit) => (
<div>
<strong>{hit.ruleName}</strong>
<small className="table-subline">
{hit.thresholdValue} {hit.actualValue}
</small>
</div>
),
},
{
key: 'window',
title: '计数周期',
width: '250px',
render: (hit) => `${formatDateTime(hit.windowStartedAt)}${formatDateTime(hit.windowEndsAt)}`,
},
{
key: 'status',
title: '状态',
width: '100px',
render: (hit) =>
hit.releasedAt ? (
<Tag tone="neutral"></Tag>
) : new Date(hit.windowEndsAt).getTime() <= Date.now() ? (
<Tag tone="warning"></Tag>
) : (
<Tag tone="danger"></Tag>
),
},
{ key: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) },
{ key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt
? <span className="muted"></span>
: <Button icon={<Unlock size={15} />} onClick={() => { setReleaseHit(hit); setReleaseReason(''); }} size="sm" variant="ghost"></Button> },
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (hit) =>
hit.releasedAt ? (
<span className="muted"></span>
) : (
<Button
icon={<Unlock size={15} />}
onClick={() => {
setReleaseHit(hit);
setReleaseReason('');
}}
size="sm"
variant="ghost"
>
</Button>
),
},
];
const whitelistColumns: Array<TableColumn<PhoneFrequencyWhitelistItem>> = [
{ key: 'phone', title: '手机号码', width: '145px', render: (item) => <strong>{item.phoneNumber}</strong> },
{ key: 'reason', title: '用途说明', render: (item) => <div><strong>{item.reason}</strong>{item.remark ? <small className="table-subline">{item.remark}</small> : null}</div> },
{ key: 'status', title: '状态', width: '90px', render: (item) => <Tag tone={item.status === 'active' ? 'success' : item.status === 'deleted' ? 'danger' : 'neutral'}>{item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{ key: 'operator', title: '最后操作人', width: '150px', render: (item) => item.updatedBy.displayName || item.updatedBy.username },
{
key: 'reason',
title: '用途说明',
render: (item) => (
<div>
<strong>{item.reason}</strong>
{item.remark ? <small className="table-subline">{item.remark}</small> : null}
</div>
),
},
{
key: 'status',
title: '状态',
width: '90px',
render: (item) => (
<Tag tone={item.status === 'active' ? 'success' : item.status === 'deleted' ? 'danger' : 'neutral'}>
{item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'}
</Tag>
),
},
{
key: 'operator',
title: '最后操作人',
width: '150px',
render: (item) => item.updatedBy.displayName || item.updatedBy.username,
},
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (item) => formatDateTime(item.updatedAt) },
{ key: 'actions', title: '操作', width: '175px', align: 'right', render: (item) => item.status === 'deleted'
? <span className="muted"></span>
: <div className="table-actions">
<Button icon={<Pencil size={15} />} onClick={() => setWhitelistEditor({
id: item.id,
phoneNumber: item.phoneNumber,
reason: item.reason,
remark: item.remark ?? '',
status: item.status === 'active' ? 'active' : 'inactive',
})} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => { setDeletingWhitelist(item); setDeleteWhitelistReason(''); }} size="sm" variant="ghost"></Button>
</div> },
{
key: 'actions',
title: '操作',
width: '175px',
align: 'right',
render: (item) =>
item.status === 'deleted' ? (
<span className="muted"></span>
) : (
<div className="table-actions">
<Button
icon={<Pencil size={15} />}
onClick={() =>
setWhitelistEditor({
id: item.id,
phoneNumber: item.phoneNumber,
reason: item.reason,
remark: item.remark ?? '',
status: item.status === 'active' ? 'active' : 'inactive',
})
}
size="sm"
variant="ghost"
>
</Button>
<Button
icon={<Trash2 size={15} />}
onClick={() => {
setDeletingWhitelist(item);
setDeleteWhitelistReason('');
}}
size="sm"
variant="ghost"
>
</Button>
</div>
),
},
];
const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20));
@@ -318,13 +503,25 @@ export function AdminRiskRulesPage() {
return (
<section className="page-stack">
<div className="page-heading">
<div><Breadcrumb items={['安全控制', '风控规则']} /><h1></h1><p></p></div>
<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>
<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}
{error ? (
<p className="form-error" role="alert">
{error}
</p>
) : null}
<div className="surface sms-audit-filter ui-filter-row">
<Select
label="查看范围"
@@ -339,17 +536,32 @@ export function AdminRiskRulesPage() {
value={applicationId}
/>
</div>
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
<div className="surface">
<Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" />
</div>
<div className="surface">
<div className="section-heading">
<div><h2></h2><p>245</p></div>
<div>
<h2></h2>
<p>245</p>
</div>
<div className="page-heading__actions">
<Tag tone="info">{whitelistTotal} </Tag>
<Button icon={<Plus size={16} />} onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}></Button>
<Button
icon={<Plus size={16} />}
onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}
>
</Button>
</div>
</div>
<div className="sms-audit-filter ui-filter-row">
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
<Input
label="手机号码"
onChange={(event) => setWhitelistPhone(event.target.value)}
placeholder="输入完整或部分号码"
value={whitelistPhone}
/>
<Select
label="白名单状态"
onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)}
@@ -362,11 +574,28 @@ export function AdminRiskRulesPage() {
value={whitelistStatus}
/>
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}></Button>
<Button onClick={() => { setWhitelistPhone(''); setWhitelistStatus(''); loadWhitelist(1, { phoneNumber: '', status: '' }); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}>
</Button>
<Button
onClick={() => {
setWhitelistPhone('');
setWhitelistStatus('');
loadWhitelist(1, { phoneNumber: '', status: '' });
}}
variant="ghost"
>
</Button>
</div>
</div>
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
<Table
columns={whitelistColumns}
data={whitelist}
emptyText="暂无号码频控白名单"
pagination={false}
rowKey="id"
/>
<Pagination
nextDisabled={whitelistPage >= whitelistTotalPages}
onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))}
@@ -380,11 +609,19 @@ export function AdminRiskRulesPage() {
</div>
<div className="surface">
<div className="section-heading">
<div><h2></h2><p></p></div>
<div>
<h2></h2>
<p></p>
</div>
<Tag tone="warning">{hitTotal} </Tag>
</div>
<div className="sms-audit-filter ui-filter-row">
<Input label="手机号码" onChange={(event) => setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} />
<Input
label="手机号码"
onChange={(event) => setHitPhone(event.target.value)}
placeholder="输入完整或部分号码"
value={hitPhone}
/>
<Select
label="记录状态"
onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)}
@@ -397,11 +634,35 @@ export function AdminRiskRulesPage() {
value={hitStatus}
/>
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}></Button>
<Button onClick={() => { setHitPhone(''); setHitStatus('active'); setHitPage(1); loadFrequencyHits(1, { phoneNumber: '', status: 'active' }); }} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
setHitPage(1);
loadFrequencyHits(1);
}}
>
</Button>
<Button
onClick={() => {
setHitPhone('');
setHitStatus('active');
setHitPage(1);
loadFrequencyHits(1, { phoneNumber: '', status: 'active' });
}}
variant="ghost"
>
</Button>
</div>
</div>
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
<Table
columns={hitColumns}
data={frequencyHits}
emptyText="暂无号码频次触发记录"
pagination={false}
rowKey="id"
/>
<Pagination
nextDisabled={hitPage >= hitTotalPages}
onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))}
@@ -413,72 +674,245 @@ export function AdminRiskRulesPage() {
totalPages={hitTotalPages}
/>
</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={isPhoneFrequencyRule(editor.code) ? '1' : '0'} onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
<Select disabled={isPhoneFrequencyRule(editor.code)} label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={isPhoneFrequencyRule(editor.code) ? [{ label: '直接拒绝(首版固定)', value: 'block' }] : [{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={isPhoneFrequencyRule(editor.code) ? 'block' : 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}
{whitelistEditor ? <Modal
footer={<><Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost"></Button><Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>{whitelistSaving ? '保存中…' : '保存'}</Button></>}
onClose={() => setWhitelistEditor(null)}
open
title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'}
>
<div className="form-grid">
<Input label="手机号码" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} />
<Select label="状态" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={whitelistEditor.status} />
</div>
<Textarea label="用途说明" maxLength={200} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })} placeholder="必填,说明该号码为何需要豁免频控" rows={3} value={whitelistEditor.reason} />
<Textarea label="备注" maxLength={500} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })} placeholder="选填" rows={3} value={whitelistEditor.remark} />
<p className="muted"></p>
</Modal> : null}
{deletingWhitelist ? <Modal
footer={<><Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost"></Button><Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>{whitelistSaving ? '处理中…' : '删除并清零'}</Button></>}
onClose={() => setDeletingWhitelist(null)}
open
title="删除号码频控白名单"
>
<p> <strong>{deletingWhitelist.phoneNumber}</strong> </p>
<Textarea label="删除原因" maxLength={500} onChange={(event) => setDeleteWhitelistReason(event.target.value)} placeholder="请填写删除原因" rows={4} value={deleteWhitelistReason} />
</Modal> : null}
{releaseHit ? <Modal
footer={<><Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost"></Button><Button disabled={releasing} onClick={() => void confirmRelease()}>{releasing ? '处理中…' : '解除并清零'}</Button></>}
onClose={() => setReleaseHit(null)}
open
title="解除号码频控"
>
<p> <strong>{releaseHit.phoneNumber}</strong> {releaseHit.application.name}</p>
<Textarea label="解除原因" onChange={(event) => setReleaseReason(event.target.value)} placeholder="请填写人工解除原因" rows={4} value={releaseReason} />
</Modal> : null}
{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),
startTime: globalRule?.config?.startTime ?? '21:00',
endTime: globalRule?.config?.endTime ?? '08:00',
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={isPhoneFrequencyRule(editor.code) ? '1' : '0'}
onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })}
type="number"
value={editor.thresholdValue}
/>
<Select
disabled={isPhoneFrequencyRule(editor.code) || editor.code === 'NON_WORKING_MARKETING_BULK'}
label="处理动作"
onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })}
options={
isPhoneFrequencyRule(editor.code)
? [{ label: '直接拒绝(首版固定)', value: 'block' }]
: [
{ label: '直接拒绝', value: 'block' },
{ label: '进入人工审核', value: 'manual_review' },
]
}
value={
isPhoneFrequencyRule(editor.code)
? 'block'
: editor.code === 'NON_WORKING_MARKETING_BULK'
? 'manual_review'
: 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}
/>
<p className="muted">
CMPPHTTP和客户端累计所有业务短信
</p>
</>
) : null}
</div>
</Modal>
) : null}
{whitelistEditor ? (
<Modal
footer={
<>
<Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost">
</Button>
<Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>
{whitelistSaving ? '保存中…' : '保存'}
</Button>
</>
}
onClose={() => setWhitelistEditor(null)}
open
title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'}
>
<div className="form-grid">
<Input
label="手机号码"
onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })}
placeholder="中国大陆11位手机号码"
value={whitelistEditor.phoneNumber}
/>
<Select
label="状态"
onChange={(event) =>
setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })
}
options={[
{ label: '启用', value: 'active' },
{ label: '停用', value: 'inactive' },
]}
value={whitelistEditor.status}
/>
</div>
<Textarea
label="用途说明"
maxLength={200}
onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })}
placeholder="必填,说明该号码为何需要豁免频控"
rows={3}
value={whitelistEditor.reason}
/>
<Textarea
label="备注"
maxLength={500}
onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })}
placeholder="选填"
rows={3}
value={whitelistEditor.remark}
/>
<p className="muted">
</p>
</Modal>
) : null}
{deletingWhitelist ? (
<Modal
footer={
<>
<Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost">
</Button>
<Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>
{whitelistSaving ? '处理中…' : '删除并清零'}
</Button>
</>
}
onClose={() => setDeletingWhitelist(null)}
open
title="删除号码频控白名单"
>
<p>
<strong>{deletingWhitelist.phoneNumber}</strong>{' '}
</p>
<Textarea
label="删除原因"
maxLength={500}
onChange={(event) => setDeleteWhitelistReason(event.target.value)}
placeholder="请填写删除原因"
rows={4}
value={deleteWhitelistReason}
/>
</Modal>
) : null}
{releaseHit ? (
<Modal
footer={
<>
<Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost">
</Button>
<Button disabled={releasing} onClick={() => void confirmRelease()}>
{releasing ? '处理中…' : '解除并清零'}
</Button>
</>
}
onClose={() => setReleaseHit(null)}
open
title="解除号码频控"
>
<p>
<strong>{releaseHit.phoneNumber}</strong> {releaseHit.application.name}
</p>
<Textarea
label="解除原因"
onChange={(event) => setReleaseReason(event.target.value)}
placeholder="请填写人工解除原因"
rows={4}
value={releaseReason}
/>
</Modal>
) : null}
</section>
);
}
+311 -92
View File
@@ -1,7 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabel: Record<string, string> = {
@@ -17,21 +31,24 @@ const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
};
function sourceLabel(sourceType?: string) {
if (sourceType === 'night_sending_bulk') return '夜间超量聚合';
return sourceType === 'cmpp_template_mismatch' ? 'CMPP模板不匹配聚合' : '风控审核';
}
function messageStatusLabel(status: string) {
return {
pending_review: '待人工审核',
queued: '已入队',
scheduled: '等待定时发送',
submitted: '供应商已受理',
delivered: '送达成功',
submit_failed: '提交失败',
failed: '回执失败',
rejected: '已拒绝',
timeout: '超时',
}[status] ?? status;
return (
{
pending_review: '待人工审核',
queued: '已入队',
scheduled: '等待定时发送',
submitted: '供应商已受理',
delivered: '送达成功',
submit_failed: '提交失败',
failed: '回执失败',
rejected: '已拒绝',
timeout: '超时',
}[status] ?? status
);
}
export function AdminSmsAuditPage() {
@@ -56,14 +73,17 @@ export function AdminSmsAuditPage() {
}
function loadData() {
adminApi.listRiskReviewTasks({
status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end,
})
adminApi
.listRiskReviewTasks({
status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end,
})
.then((items) => {
setRecords(items);
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')));
setSelectedIds((current) =>
current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')),
);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
@@ -74,16 +94,20 @@ export function AdminSmsAuditPage() {
}, [status, submittedDateRange.end, submittedDateRange.start]);
const filteredRecords = useMemo(
() => records.filter((record) => {
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
return matchesKeyword;
}),
() =>
records.filter((record) => {
const matchesKeyword =
!keyword ||
[record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
return matchesKeyword;
}),
[keyword, records],
);
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
if (!target) return;
adminApi.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
adminApi
.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
.then(setPhoneData)
.catch((failure: Error) => setError(failure.message || '审核号码列表加载失败'));
}
@@ -131,9 +155,29 @@ export function AdminSmsAuditPage() {
const columns: Array<TableColumn<RiskReviewTask>> = [
{
key: 'selection',
title: <input aria-label="全选当前筛选结果" checked={allSelected} disabled={selectableIds.length === 0} onChange={(event) => setSelectedIds(event.target.checked ? selectableIds : [])} type="checkbox" />,
title: (
<input
aria-label="全选当前筛选结果"
checked={allSelected}
disabled={selectableIds.length === 0}
onChange={(event) => setSelectedIds(event.target.checked ? selectableIds : [])}
type="checkbox"
/>
),
width: '54px',
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
render: (record) => (
<input
aria-label={`选择审核任务${record.taskNo}`}
checked={selectedIds.includes(record.id)}
disabled={record.status !== 'pending_review'}
onChange={(event) =>
setSelectedIds((current) =>
event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id),
)
}
type="checkbox"
/>
),
},
{
key: 'tenantApplication',
@@ -147,7 +191,12 @@ export function AdminSmsAuditPage() {
</div>
),
},
{ key: 'content', title: '短信内容', width: '440px', render: (record) => <span className="table-long-text">{record.content}</span> },
{
key: 'content',
title: '短信内容',
width: '440px',
render: (record) => <span className="table-long-text">{record.content}</span>,
},
{
key: 'submittedSource',
title: '提交时间 / 审核来源',
@@ -155,7 +204,9 @@ export function AdminSmsAuditPage() {
render: (record) => (
<div className="sms-audit-cell-stack">
<time dateTime={record.createdAt}>{formatDateTime(record.createdAt)}</time>
<Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag>
<Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>
{sourceLabel(record.sourceType)}
</Tag>
</div>
),
},
@@ -165,7 +216,15 @@ export function AdminSmsAuditPage() {
width: '160px',
render: (record) => (
<div className="sms-audit-cell-stack">
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">
<button
className="table-link"
onClick={() => {
setPhoneTarget(record);
setPhoneKeyword('');
setPhonePage(1);
}}
type="button"
>
{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} ·
</button>
<Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>
@@ -179,11 +238,19 @@ export function AdminSmsAuditPage() {
align: 'right',
render: (record) => (
<div className="audit-actions">
<Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost"></Button>
{record.status === 'pending_review' ? <>
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button>
</> : null}
<Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost">
</Button>
{record.status === 'pending_review' ? (
<>
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success">
</Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">
</Button>
</>
) : null}
</div>
),
},
@@ -207,17 +274,47 @@ export function AdminSmsAuditPage() {
]}
value={status}
/>
<Input label="审核任务号/短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入审核任务号、短信内容或审核原因" value={keyword} />
<Input
label="审核任务号/短信内容"
onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入审核任务号、短信内容或审核原因"
value={keyword}
/>
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setSubmittedDateRange({}); setStatus('pending_review'); }} variant="ghost"></Button>
<Button icon={<Search size={17} />} onClick={loadData}>
</Button>
<Button
onClick={() => {
setKeyword('');
setSubmittedDateRange({});
setStatus('pending_review');
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="sms-bulk-actions">
<span> {selectedIds.length} </span>
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
<Button disabled={selectedIds.length === 0} icon={<X size={16} />} onClick={() => setRejectTarget('batch')} variant="danger"></Button>
<Button
disabled={selectedIds.length === 0}
icon={<Check size={16} />}
onClick={() => setApproveTarget('batch')}
variant="success"
>
</Button>
<Button
disabled={selectedIds.length === 0}
icon={<X size={16} />}
onClick={() => setRejectTarget('batch')}
variant="danger"
>
</Button>
</div>
</div>
@@ -226,78 +323,200 @@ export function AdminSmsAuditPage() {
</div>
<Modal
footer={(
footer={
<>
<Button onClick={() => setApproveTarget(null)} variant="ghost"></Button>
<Button onClick={() => approveTarget === 'batch' ? void approveBatch() : approveTarget ? void approveRecord(approveTarget) : undefined} variant="success"></Button>
<Button onClick={() => setApproveTarget(null)} variant="ghost">
</Button>
<Button
onClick={() =>
approveTarget === 'batch'
? void approveBatch()
: approveTarget
? void approveRecord(approveTarget)
: undefined
}
variant="success"
>
</Button>
</>
)}
}
onClose={() => setApproveTarget(null)}
open={Boolean(approveTarget)}
title="确认通过"
>
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
<p>
{approveTarget === 'batch'
? `确认通过已选择的 ${selectedIds.length} 条待审核任务?`
: '确认通过该短信审核任务?'}
</p>
</Modal>
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="短信审核详情">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong></div>
<div><span></span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div>
<div><span></span><strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.reviewReason || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.rejectReason || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{detailTarget.riskHits?.map((item) => item.reason).join('') || '-'}</strong></div>
</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="ui-filter-row">
<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 ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div>
{detailTarget ? (
<Modal
footer={<Button onClick={() => setDetailTarget(null)}></Button>}
onClose={() => setDetailTarget(null)}
open
title="短信审核详情"
>
<div className="detail-grid">
<div>
<span></span>
<strong>{detailTarget.taskNo}</strong>
</div>
<div>
<span></span>
<strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{formatDateTime(detailTarget.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{detailTarget.reviewedBy?.username || '-'}</strong>
</div>
<div>
<span></span>
<strong>{formatDateTime(detailTarget.reviewedAt)}</strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{detailTarget.reviewReason || '-'}</strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{detailTarget.rejectReason || '-'}</strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{detailTarget.riskHits?.map((item) => item.reason).join('') || '-'}</strong>
</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 ? <CarrierTag carrier={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>
) : null}
{phoneTarget ? (
<Modal
footer={<Button onClick={() => setPhoneTarget(null)}></Button>}
onClose={() => setPhoneTarget(null)}
open
size="xl"
title={`号码列表 · 审核任务号 ${phoneTarget.taskNo}`}
>
<div className="page-stack">
<div className="ui-filter-row">
<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 ui-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 ? <CarrierTag carrier={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={(
footer={
<>
<Button onClick={() => setRejectTarget(null)} variant="ghost"></Button>
<Button disabled={!rejectReason.trim()} onClick={() => rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord()} variant="danger"></Button>
<Button onClick={() => setRejectTarget(null)} variant="ghost">
</Button>
<Button
disabled={!rejectReason.trim()}
onClick={() => (rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord())}
variant="danger"
>
</Button>
</>
)}
}
onClose={() => setRejectTarget(null)}
open={Boolean(rejectTarget)}
title={rejectTarget === 'batch' ? '批量驳回' : '确认驳回'}
>
{rejectTarget === 'batch' ? <p> {selectedIds.length} </p> : null}
<Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} />
<Textarea
label="驳回原因"
onChange={(event) => setRejectReason(event.target.value)}
rows={4}
value={rejectReason}
/>
</Modal>
</section>
);