485 lines
26 KiB
TypeScript
485 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|