From e98402d75c67e62e4e7758eea65c32809d832f6c Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Thu, 30 Jul 2026 20:21:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E9=85=8D=E7=BD=AE=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=8E=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/limit-whitelist/page.tsx | 713 +++++++++++++++--------- src/lib/types.ts | 15 + 2 files changed, 478 insertions(+), 250 deletions(-) diff --git a/src/app/(main)/limit-whitelist/page.tsx b/src/app/(main)/limit-whitelist/page.tsx index 57cc458..8add99b 100644 --- a/src/app/(main)/limit-whitelist/page.tsx +++ b/src/app/(main)/limit-whitelist/page.tsx @@ -12,7 +12,7 @@ import { Input, InputNumber, Modal, - Popconfirm, + Popover, Select, Space, Switch, @@ -22,21 +22,16 @@ import { Typography, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { - DeleteOutlined, - EditOutlined, - PlusOutlined, - ReloadOutlined, - UndoOutlined, -} from '@ant-design/icons'; +import { EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import type { LimitDeviceCandidate, LimitOverride, - LimitOverrideList, LimitRule, + LimitSubject, + LimitSubjectList, LimitSubjectType, } from '@/lib/types'; @@ -60,9 +55,8 @@ interface OverrideFormValue { subject_type: LimitSubjectType; subject_value: string; rule_codes: string[]; - enabled: boolean; - starts_at?: Dayjs | null; - expires_at?: Dayjs | null; + enabled?: boolean; + valid_range?: [Dayjs, Dayjs] | null; reason?: string; } @@ -72,24 +66,41 @@ interface CreatePreset { rule_code: string; } +interface WhitelistFilters { + keyword: string; + subjectType?: LimitSubjectType; + ruleCode?: string; +} + const asIso = (value?: Dayjs | null) => (value ? value.toISOString() : null); const showTime = (value: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'; +const normalizeSubjectValue = ( + subjectType: LimitSubjectType, + value: string, +) => + subjectType === 'phone' + ? value.replace(/\D/g, '') + : value.trim(); export default function LimitWhitelistPage() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const [rules, setRules] = useState([]); - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); + const [pageSize, setPageSize] = useState(10); const [loading, setLoading] = useState(true); const [keyword, setKeyword] = useState(''); const [subjectType, setSubjectType] = useState(); const [ruleFilter, setRuleFilter] = useState(); - const [editing, setEditing] = useState(null); + const [appliedFilters, setAppliedFilters] = useState({ + keyword: '', + }); + const [editing, setEditing] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [saving, setSaving] = useState(false); + const [togglingSubject, setTogglingSubject] = useState(null); const [createPreset, setCreatePreset] = useState(null); const [deviceCandidates, setDeviceCandidates] = useState< LimitDeviceCandidate[] @@ -105,6 +116,11 @@ export default function LimitWhitelistPage() { const watchedRuleCodes = Form.useWatch('rule_codes', form) || []; const watchedSubjectType = Form.useWatch('subject_type', form); const watchedSubjectValue = Form.useWatch('subject_value', form); + const effectiveSubjectType = + watchedSubjectType || + editing?.subject_type || + createPreset?.subject_type || + 'phone'; const candidateRuleCode = useMemo(() => { if (watchedSubjectType === 'device') { const scopes = new Set(watchedRuleCodes.map(deviceSourceScope)); @@ -153,11 +169,11 @@ export default function LimitWhitelistPage() { try { const [ruleResponse, overrideResponse] = await Promise.all([ api.get('/admin/api/limit-whitelist/rules'), - api.get('/admin/api/limit-whitelist', { + api.get('/admin/api/limit-whitelist/subjects', { params: { - keyword: keyword.trim() || undefined, - subject_type: subjectType, - rule_code: ruleFilter, + keyword: appliedFilters.keyword.trim() || undefined, + subject_type: appliedFilters.subjectType, + rule_code: appliedFilters.ruleCode, offset: (page - 1) * pageSize, limit: pageSize, }, @@ -171,12 +187,21 @@ export default function LimitWhitelistPage() { } finally { setLoading(false); } - }, [keyword, message, page, pageSize, ruleFilter, subjectType]); + }, [appliedFilters, message, page, pageSize]); useEffect(() => { void load(); }, [load]); + const applyFilters = () => { + setPage(1); + setAppliedFilters({ + keyword, + subjectType, + ruleCode: ruleFilter, + }); + }; + const openCreate = () => { setCreatePreset(null); setDeviceCandidates([]); @@ -185,9 +210,12 @@ export default function LimitWhitelistPage() { setEditorOpen(true); }; - const openEdit = (row: LimitOverride) => { + const openEdit = (row: LimitSubject) => { setEditing(row); - setEditorRuleGroup(row.rule_group); + const firstSelectedGroup = RULE_GROUPS.find( + (group) => (row.group_counts[group] || 0) > 0, + ); + setEditorRuleGroup(firstSelectedGroup || RULE_GROUPS[0]); setEditorOpen(true); }; @@ -222,14 +250,25 @@ export default function LimitWhitelistPage() { const editorInitialValues = useMemo>(() => { if (editing) { + const startsAt = editing.items + .map((item) => item.starts_at) + .filter((value): value is string => Boolean(value)) + .sort(); + const expiresAt = editing.items + .map((item) => item.expires_at) + .filter((value): value is string => Boolean(value)) + .sort() + .at(-1); + const reasons = new Set(editing.items.map((item) => item.reason || '')); return { subject_type: editing.subject_type, subject_value: editing.subject_value, - rule_codes: [editing.rule_code], - enabled: editing.enabled, - starts_at: editing.starts_at ? dayjs(editing.starts_at) : null, - expires_at: editing.expires_at ? dayjs(editing.expires_at) : null, - reason: editing.reason || '', + rule_codes: editing.items.map((item) => item.rule_code), + enabled: editing.items.every((item) => item.enabled), + valid_range: expiresAt + ? [startsAt[0] ? dayjs(startsAt[0]) : dayjs(), dayjs(expiresAt)] + : null, + reason: reasons.size === 1 ? Array.from(reasons)[0] : '', }; } if (createPreset) { @@ -237,17 +276,15 @@ export default function LimitWhitelistPage() { subject_type: createPreset.subject_type, subject_value: createPreset.subject_value, rule_codes: [createPreset.rule_code], - enabled: true, - expires_at: dayjs().add(1, 'day'), + valid_range: [dayjs(), dayjs().add(1, 'month')], }; } return { subject_type: 'phone', rule_codes: [], - enabled: true, - expires_at: dayjs().add(1, 'day'), + valid_range: [dayjs(), dayjs().add(1, 'month')], }; - }, [createPreset, editing]); + }, [createPreset, editing, editorOpen]); useEffect(() => { if ( @@ -297,23 +334,87 @@ export default function LimitWhitelistPage() { } catch { return; } + const normalizedSubjectValue = normalizeSubjectValue( + value.subject_type, + value.subject_value, + ); + if (!normalizedSubjectValue) { + message.error( + value.subject_type === 'phone' + ? '请输入有效手机号' + : '请选择有效设备', + ); + return; + } + if (!editing) { + setSaving(true); + let existingSubject: LimitSubject | undefined; + try { + const response = await api.get( + '/admin/api/limit-whitelist/subjects', + { + params: { + subject_type: value.subject_type, + keyword: normalizedSubjectValue, + offset: 0, + limit: 100, + }, + }, + ); + existingSubject = response.data.items.find( + (item) => + item.subject_type === value.subject_type && + item.subject_value === normalizedSubjectValue, + ); + } catch (error) { + message.error(errMsg(error, '白名单重复配置检查失败')); + return; + } finally { + setSaving(false); + } + + if (existingSubject) { + const isPhone = value.subject_type === 'phone'; + const confirmed = await new Promise((resolve) => { + modal.confirm({ + title: isPhone + ? '该手机号已有白名单配置' + : '该设备已有白名单配置', + content: isPhone + ? '该手机号已有白名单配置,新增配置是以追加配置的方式;有效期将统一为本次配置的有效期。' + : '该设备已有白名单配置,新增配置是以追加配置的方式;有效期将统一为本次配置的有效期。', + okText: '确认追加', + cancelText: '取消', + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + if (!confirmed) return; + } + } + setSaving(true); try { + const [startsAt, expiresAt] = value.valid_range || []; + const enabled = editing ? value.enabled : true; if (editing) { - await api.patch(`/admin/api/limit-whitelist/${editing.id}`, { - enabled: value.enabled, - starts_at: asIso(value.starts_at), - expires_at: asIso(value.expires_at), + await api.put('/admin/api/limit-whitelist/subjects', { + subject_type: value.subject_type, + subject_value: normalizedSubjectValue, + rule_codes: value.rule_codes, + enabled, + starts_at: asIso(startsAt), + expires_at: asIso(expiresAt), reason: value.reason || '', }); } else { await api.post('/admin/api/limit-whitelist/bulk', { subject_type: value.subject_type, - subject_value: value.subject_value, + subject_value: normalizedSubjectValue, rule_codes: value.rule_codes, - enabled: value.enabled, - starts_at: asIso(value.starts_at), - expires_at: asIso(value.expires_at), + enabled, + starts_at: asIso(startsAt), + expires_at: asIso(expiresAt), reason: value.reason || '', }); } @@ -335,40 +436,6 @@ export default function LimitWhitelistPage() { } }; - const updateEnabled = async (row: LimitOverride, enabled: boolean) => { - try { - await api.patch(`/admin/api/limit-whitelist/${row.id}`, { enabled }); - message.success(enabled ? '配置已启用' : '配置已停用'); - await load(); - } catch (error) { - message.error(errMsg(error)); - } - }; - - const restoreGlobal = async (row: LimitOverride) => { - try { - await api.post(`/admin/api/limit-whitelist/${row.id}/reset`); - message.success('已停用例外配置并恢复全局策略'); - await load(); - } catch (error) { - message.error(errMsg(error)); - } - }; - - const deleteRow = async (row: LimitOverride) => { - try { - await api.delete(`/admin/api/limit-whitelist/${row.id}`); - message.success('配置已删除'); - if (rows.length === 1 && page > 1) { - setPage(page - 1); - } else { - await load(); - } - } catch (error) { - message.error(errMsg(error)); - } - }; - const saveGlobal = async () => { if (!globalRule || globalValue == null) return; setGlobalSaving(true); @@ -386,6 +453,25 @@ export default function LimitWhitelistPage() { } }; + const toggleSubject = async (row: LimitSubject, enabled: boolean) => { + const subjectKey = `${row.subject_type}:${row.subject_value}`; + setTogglingSubject(subjectKey); + try { + await api.patch('/admin/api/limit-whitelist/subjects/enabled', { + subject_type: row.subject_type, + subject_value: row.subject_value, + enabled, + }); + message.success(enabled ? '白名单已启用' : '白名单已停用'); + await load(); + } catch (error) { + message.error(errMsg(error, '白名单启用状态更新失败')); + await load(); + } finally { + setTogglingSubject(null); + } + }; + const ruleColumns: ColumnsType = [ { title: '限制项', @@ -429,126 +515,250 @@ export default function LimitWhitelistPage() { }, ]; - const columns: ColumnsType = [ + const columns: ColumnsType = [ { title: '主体', key: 'subject', - width: 190, + width: 300, render: (_, row) => ( - - - {row.subject_type === 'phone' ? '手机号' : '设备'} - - {row.subject_value} + + {row.subject_type === 'device' && 设备} + ), }, { title: '限制项', key: 'rule', - width: 250, - render: (_, row) => {row.rule_label}, - }, - { - title: '白名单效果', - key: 'mode', - width: 145, - render: (_, row) => - row.mode === 'suppress_alert' ? ( - 免告警 - ) : row.mode === 'unlimited' ? ( - 临时不限 - ) : ( - 历史配置 - ), - }, - { - title: '实际值', - key: 'effective', - width: 120, - render: (_, row) => - row.mode === 'suppress_alert' - ? '正常计数,免告警' - : row.effective_limit == null - ? '不限' - : `${row.effective_limit}(全局 ${row.global_limit})`, + render: (_, row) => { + const groupCounts = Object.fromEntries( + RULE_GROUPS.map((group) => [ + group, + row.items.filter((item) => item.rule_group === group).length, + ]), + ); + return ( + + {RULE_GROUPS.map((group, groupIndex) => { + const items = row.items.filter( + (item) => item.rule_group === group, + ); + return ( +
+
+ {group} + {items.length} 项 +
+ {items.length ? ( + items.map((item) => ( +
+ {item.rule_label} + + {STATUS_META[item.status].label} + +
+ )) + ) : ( + + 暂无配置 + + )} +
+ ); + })} + + } + > +
+ {RULE_GROUPS.map((group) => { + const count = groupCounts[group] || 0; + return ( + + {group} + + {count} + + + ); + })} +
+
+ ); + }, }, { title: '有效期', key: 'time', - width: 180, - render: (_, row) => ( - - {showTime(row.starts_at)} - 至 {showTime(row.expires_at)} - - ), + width: 190, + render: (_, row) => { + const startsAt = Array.from( + new Set(row.items.map((item) => item.starts_at)), + ); + const expiresAt = Array.from( + new Set(row.items.map((item) => item.expires_at)), + ).sort(); + if (startsAt.length === 1 && expiresAt.length === 1) { + return ( + + {showTime(startsAt[0])} + 至 {showTime(expiresAt[0])} + + ); + } + return ( + + 共 {row.total_rules} 条配置 + + 最早失效 {showTime(expiresAt[0] || null)} + + + 最晚失效 {showTime(expiresAt.at(-1) || null)} + + + ); + }, }, { title: '状态', key: 'status', - width: 115, - render: (_, row) => ( - - {STATUS_META[row.status].label} - - ), + width: 190, + render: (_, row) => { + const counts = row.items.reduce< + Partial> + >((result, item) => { + result[item.status] = (result[item.status] || 0) + 1; + return result; + }, {}); + return ( + + {Object.entries(STATUS_META).map(([status, meta]) => + counts[status as LimitOverride['status']] ? ( + + {meta.label} + + ) : null, + )} + + ); + }, + }, + { + title: '备注', + key: 'reason', + width: 220, + render: (_, row) => { + const reasons = Array.from( + new Set( + row.items + .map((item) => item.reason) + .filter((value): value is string => Boolean(value)), + ), + ); + const text = + reasons.length <= 1 + ? reasons[0] || '-' + : `${reasons[0]} 等 ${reasons.length} 种`; + return {text}; + }, }, - { title: '原因', dataIndex: 'reason', ellipsis: true, render: (v) => v || '-' }, { title: '启用', key: 'enabled', - width: 80, - fixed: 'right', - render: (_, row) => ( - void updateEnabled(row, checked)} - /> - ), - }, - { - title: '操作', - key: 'actions', - width: 210, - fixed: 'right', - render: (_, row) => ( - - {row.mode !== 'override' && ( - - )} - {row.enabled && ( - void restoreGlobal(row)} - > - - - )} - void deleteRow(row)} - > -