Files
lislgosms/src/apps/admin/AdminRiskRulesPage.tsx
T

485 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Pencil, Plus, RefreshCw, Search, Trash2, Unlock } from 'lucide-react';
import {
adminApi,
type EnterpriseApplication,
type PhoneFrequencyHit,
type PhoneFrequencyWhitelistItem,
type RiskRuleItem,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type TableColumn,
} from '@/components/ui';
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: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
{ code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' },
{ code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' },
];
function isPhoneFrequencyRule(code: RiskRuleItem['code']) {
return code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M';
}
type EditorState = {
id?: string;
applicationId: string;
code: RiskRuleItem['code'];
thresholdValue: string;
action: RiskRuleItem['action'];
status: RiskRuleItem['status'];
priority: string;
startTime: string;
endTime: string;
};
type WhitelistEditorState = {
id?: string;
phoneNumber: string;
reason: string;
remark: string;
status: 'active' | 'inactive';
};
function editorFromRule(rule?: RiskRuleItem): EditorState {
return {
id: rule?.id,
applicationId: rule?.applicationId ?? '',
code: rule?.code ?? 'MAX_PHONES_PER_TASK',
thresholdValue: String(rule?.thresholdValue ?? 100000),
action: rule?.action ?? 'block',
status: rule?.status ?? 'active',
priority: String(rule?.priority ?? 100),
startTime: rule?.config?.startTime ?? '21:00',
endTime: rule?.config?.endTime ?? '08:00',
};
}
export function AdminRiskRulesPage() {
const [rules, setRules] = useState<RiskRuleItem[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [applicationId, setApplicationId] = useState('');
const [editor, setEditor] = useState<EditorState | null>(null);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [frequencyHits, setFrequencyHits] = useState<PhoneFrequencyHit[]>([]);
const [hitPhone, setHitPhone] = useState('');
const [hitStatus, setHitStatus] = useState<'active' | 'expired' | 'released' | ''>('active');
const [hitPage, setHitPage] = useState(1);
const [hitTotal, setHitTotal] = useState(0);
const [releaseHit, setReleaseHit] = useState<PhoneFrequencyHit | null>(null);
const [releaseReason, setReleaseReason] = useState('');
const [releasing, setReleasing] = useState(false);
const [whitelist, setWhitelist] = useState<PhoneFrequencyWhitelistItem[]>([]);
const [whitelistPhone, setWhitelistPhone] = useState('');
const [whitelistStatus, setWhitelistStatus] = useState<'active' | 'inactive' | 'deleted' | ''>('');
const [whitelistPage, setWhitelistPage] = useState(1);
const [whitelistTotal, setWhitelistTotal] = useState(0);
const [whitelistEditor, setWhitelistEditor] = useState<WhitelistEditorState | null>(null);
const [whitelistSaving, setWhitelistSaving] = useState(false);
const [deletingWhitelist, setDeletingWhitelist] = useState<PhoneFrequencyWhitelistItem | null>(null);
const [deleteWhitelistReason, setDeleteWhitelistReason] = useState('');
function load() {
Promise.all([
adminApi.listRiskRules(applicationId || undefined),
applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications),
]).then(([nextRules, nextApplications]) => {
setRules(nextRules);
setApplications(nextApplications);
setError('');
}).catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
}
useEffect(load, [applicationId]);
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 || '号码频次触发记录加载失败'));
}
useEffect(() => {
setHitPage(1);
loadFrequencyHits(1);
}, [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 || '号码频控白名单加载失败'));
}
useEffect(() => {
loadWhitelist(1);
}, []);
const existingCodes = useMemo(
() => new Set(rules.filter((rule) => rule.applicationId === editor?.applicationId).map((rule) => rule.code)),
[editor?.applicationId, rules],
);
async function save() {
if (!editor) return;
if (!editor.id && !editor.applicationId) {
setError('请选择企业应用');
return;
}
const thresholdValue = Number(editor.thresholdValue);
if (!Number.isFinite(thresholdValue) || thresholdValue < 0) {
setError('阈值必须是大于等于0的数字');
return;
}
setSaving(true);
setError('');
const phoneFrequencyRule = isPhoneFrequencyRule(editor.code);
const body = {
thresholdValue,
action: phoneFrequencyRule ? 'block' 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,
};
try {
if (editor.id) {
await adminApi.updateRiskRule(editor.id, body);
} else {
await adminApi.createRiskRule({
...body,
applicationId: editor.applicationId || undefined,
code: editor.code,
});
}
setEditor(null);
load();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '风控规则保存失败');
} finally {
setSaving(false);
}
}
async function confirmRelease() {
if (!releaseHit) return;
if (!releaseReason.trim()) {
setError('解除并清零时必须填写原因');
return;
}
setReleasing(true);
try {
await adminApi.releasePhoneFrequencyHit(releaseHit.id, releaseReason.trim());
setReleaseHit(null);
setReleaseReason('');
loadFrequencyHits();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '解除号码频控失败');
} finally {
setReleasing(false);
}
}
async function saveWhitelist() {
if (!whitelistEditor) return;
if (!/^(\+?86)?1\d{10}$/.test(whitelistEditor.phoneNumber.replace(/[\s-]/g, ''))) {
setError('请输入有效的中国大陆11位手机号码');
return;
}
if (!whitelistEditor.reason.trim()) {
setError('白名单用途说明不能为空');
return;
}
setWhitelistSaving(true);
setError('');
try {
const body = {
phoneNumber: whitelistEditor.phoneNumber.trim(),
reason: whitelistEditor.reason.trim(),
remark: whitelistEditor.remark.trim(),
status: whitelistEditor.status,
};
if (whitelistEditor.id) {
await adminApi.updatePhoneFrequencyWhitelist(whitelistEditor.id, body);
} else {
await adminApi.createPhoneFrequencyWhitelist(body);
}
setWhitelistEditor(null);
loadWhitelist(1);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '号码频控白名单保存失败');
} finally {
setWhitelistSaving(false);
}
}
async function confirmDeleteWhitelist() {
if (!deletingWhitelist) return;
if (!deleteWhitelistReason.trim()) {
setError('删除白名单时必须填写原因');
return;
}
setWhitelistSaving(true);
setError('');
try {
await adminApi.deletePhoneFrequencyWhitelist(deletingWhitelist.id, deleteWhitelistReason.trim());
setDeletingWhitelist(null);
setDeleteWhitelistReason('');
loadWhitelist(1);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '号码频控白名单删除失败');
} finally {
setWhitelistSaving(false);
}
}
const columns: Array<TableColumn<RiskRuleItem>> = [
{ key: 'name', title: '规则名称', render: (rule) => <div><strong>{rule.name}</strong><small className="table-subline">{rule.description}</small></div> },
{ key: 'scope', title: '适用范围', render: (rule) => rule.application ? <div><strong>{rule.application.name}</strong><small className="table-subline">{rule.application.tenant?.name ?? '-'}</small></div> : <Tag tone="info">全局默认</Tag> },
{ key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` },
{ key: 'time', title: '生效时间', width: '180px', render: (rule) => {
if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-';
const start = rule.config?.startTime ?? '21:00';
const end = rule.config?.endTime ?? '08:00';
return `${start}${start > end ? '次日' : ''}${end}`;
} },
{ key: 'action', title: '处理动作', width: '120px', render: (rule) => <Tag tone={rule.action === 'block' ? 'danger' : 'warning'}>{rule.action === 'block' ? '直接拒绝' : '人工审核'}</Tag> },
{ key: 'status', title: '状态', width: '100px', render: (rule) => <Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag> },
{ key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority },
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">编辑</Button> },
];
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: '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> },
];
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: '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> },
];
const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20));
const whitelistTotalPages = Math.max(1, Math.ceil(whitelistTotal / 20));
return (
<section className="page-stack">
<div className="page-heading">
<div><Breadcrumb items={['安全控制', '风控规则']} /><h1>风控规则</h1><p>维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。</p></div>
<div className="page-heading__actions">
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost">刷新</Button>
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}>新增应用覆盖</Button>
</div>
</div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<div className="surface sms-audit-filter ui-filter-row">
<Select
label="查看范围"
onChange={(event) => setApplicationId(event.target.value)}
options={[
{ label: '全部全局规则', value: '' },
...applications.map((application) => ({
label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`,
value: application.id,
})),
]}
value={applicationId}
/>
</div>
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
<div className="surface">
<div className="section-heading">
<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>
</div>
</div>
<div className="sms-audit-filter ui-filter-row">
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
<Select
label="白名单状态"
onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)}
options={[
{ label: '全部有效记录', value: '' },
{ label: '启用', value: 'active' },
{ label: '停用', value: 'inactive' },
{ label: '已删除', value: 'deleted' },
]}
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>
</div>
</div>
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
<Pagination
nextDisabled={whitelistPage >= whitelistTotalPages}
onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))}
onPageChange={(page) => loadWhitelist(page)}
onPrevious={() => loadWhitelist(Math.max(1, whitelistPage - 1))}
page={whitelistPage}
previousDisabled={whitelistPage <= 1}
total={whitelistTotal}
totalPages={whitelistTotalPages}
/>
</div>
<div className="surface">
<div className="section-heading">
<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} />
<Select
label="记录状态"
onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)}
options={[
{ label: '全部状态', value: '' },
{ label: '拦截中', value: 'active' },
{ label: '周期已到期', value: 'expired' },
{ label: '已人工解除', value: 'released' },
]}
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>
</div>
</div>
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
<Pagination
nextDisabled={hitPage >= hitTotalPages}
onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))}
onPageChange={(page) => loadFrequencyHits(page)}
onPrevious={() => loadFrequencyHits(Math.max(1, hitPage - 1))}
page={hitPage}
previousDisabled={hitPage <= 1}
total={hitTotal}
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}
</section>
);
}