This commit is contained in:
@@ -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>启用后,该号码在全平台所有企业应用下均不受24小时和5分钟号码频次限制;其他风控规则仍正常执行。</p></div>
|
||||
<div>
|
||||
<h2>平台级号码频控白名单</h2>
|
||||
<p>启用后,该号码在全平台所有企业应用下均不受24小时和5分钟号码频次限制;其他风控规则仍正常执行。</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">
|
||||
每个应用跨CMPP、HTTP和客户端累计所有业务短信,超过阈值进入短信审核。同一夜间跨零点连续计数,阈值修改不清零;夜间调整时段在本夜结束后生效,待审核短信仍需人工处理。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user