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([]); const [applications, setApplications] = useState([]); const [applicationId, setApplicationId] = useState(''); const [editor, setEditor] = useState(null); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); const [frequencyHits, setFrequencyHits] = useState([]); 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(null); const [releaseReason, setReleaseReason] = useState(''); const [releasing, setReleasing] = useState(false); const [whitelist, setWhitelist] = useState([]); 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(null); const [whitelistSaving, setWhitelistSaving] = useState(false); const [deletingWhitelist, setDeletingWhitelist] = useState(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> = [ { key: 'name', title: '规则名称', render: (rule) =>
{rule.name}{rule.description}
}, { key: 'scope', title: '适用范围', render: (rule) => rule.application ?
{rule.application.name}{rule.application.tenant?.name ?? '-'}
: 全局默认 }, { 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) => {rule.action === 'block' ? '直接拒绝' : '人工审核'} }, { key: 'status', title: '状态', width: '100px', render: (rule) => {rule.status === 'active' ? '启用' : '停用'} }, { key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority }, { key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => }, ]; const hitColumns: Array> = [ { key: 'phone', title: '号码', width: '140px', render: (hit) => {hit.phoneNumber} }, { key: 'scope', title: '企业 / 应用', render: (hit) =>
{hit.tenant.name}{hit.application.name}
}, { key: 'rule', title: '命中规则', render: (hit) =>
{hit.ruleName}阈值 {hit.thresholdValue} 条,触发值 {hit.actualValue} 条
}, { key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)} 至 ${formatDateTime(hit.windowEndsAt)}` }, { key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt ? 已解除 : new Date(hit.windowEndsAt).getTime() <= Date.now() ? 已到期 : 拦截中 }, { key: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) }, { key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt ? 已清零 : }, ]; const whitelistColumns: Array> = [ { key: 'phone', title: '手机号码', width: '145px', render: (item) => {item.phoneNumber} }, { key: 'reason', title: '用途说明', render: (item) =>
{item.reason}{item.remark ? {item.remark} : null}
}, { key: 'status', title: '状态', width: '90px', render: (item) => {item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'} }, { 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' ? 历史记录 :
}, ]; const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20)); const whitelistTotalPages = Math.max(1, Math.ceil(whitelistTotal / 20)); return (

风控规则

维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。

{error ?

{error}

: null}
setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} /> setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} /> 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 ? item.code === editor.code)?.label ?? editor.code} />} item.code === editor.code)?.unit ?? ''})`} min={isPhoneFrequencyRule(editor.code) ? '1' : '0'} onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} /> setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} /> setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} /> {editor.code === 'NON_WORKING_MARKETING_BULK' ? <> setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} /> setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} /> : null}
: null} {whitelistEditor ? } onClose={() => setWhitelistEditor(null)} open title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'} >
setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} />