优化:完善白名单配置交互与展示
This commit is contained in:
@@ -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<LimitRule[]>([]);
|
||||
const [rows, setRows] = useState<LimitOverride[]>([]);
|
||||
const [rows, setRows] = useState<LimitSubject[]>([]);
|
||||
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<LimitSubjectType | undefined>();
|
||||
const [ruleFilter, setRuleFilter] = useState<string | undefined>();
|
||||
const [editing, setEditing] = useState<LimitOverride | null>(null);
|
||||
const [appliedFilters, setAppliedFilters] = useState<WhitelistFilters>({
|
||||
keyword: '',
|
||||
});
|
||||
const [editing, setEditing] = useState<LimitSubject | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingSubject, setTogglingSubject] = useState<string | null>(null);
|
||||
const [createPreset, setCreatePreset] = useState<CreatePreset | null>(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<LimitRule[]>('/admin/api/limit-whitelist/rules'),
|
||||
api.get<LimitOverrideList>('/admin/api/limit-whitelist', {
|
||||
api.get<LimitSubjectList>('/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<Partial<OverrideFormValue>>(() => {
|
||||
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<LimitSubjectList>(
|
||||
'/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<boolean>((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<LimitRule> = [
|
||||
{
|
||||
title: '限制项',
|
||||
@@ -429,126 +515,250 @@ export default function LimitWhitelistPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<LimitOverride> = [
|
||||
const columns: ColumnsType<LimitSubject> = [
|
||||
{
|
||||
title: '主体',
|
||||
key: 'subject',
|
||||
width: 190,
|
||||
width: 300,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={row.subject_type === 'phone' ? 'blue' : 'cyan'}>
|
||||
{row.subject_type === 'phone' ? '手机号' : '设备'}
|
||||
</Tag>
|
||||
<Text copyable={{ text: row.subject_value }}>{row.subject_value}</Text>
|
||||
<Space size={8}>
|
||||
{row.subject_type === 'device' && <Tag color="cyan">设备</Tag>}
|
||||
<Button
|
||||
type="link"
|
||||
style={{ height: 'auto', padding: 0, textAlign: 'left' }}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
{row.subject_value}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '限制项',
|
||||
key: 'rule',
|
||||
width: 250,
|
||||
render: (_, row) => <Text>{row.rule_label}</Text>,
|
||||
},
|
||||
{
|
||||
title: '白名单效果',
|
||||
key: 'mode',
|
||||
width: 145,
|
||||
render: (_, row) =>
|
||||
row.mode === 'suppress_alert' ? (
|
||||
<Tag color="purple">免告警</Tag>
|
||||
) : row.mode === 'unlimited' ? (
|
||||
<Tag color="green">临时不限</Tag>
|
||||
) : (
|
||||
<Tag>历史配置</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Popover
|
||||
trigger={['hover', 'click']}
|
||||
placement="topLeft"
|
||||
mouseEnterDelay={0.15}
|
||||
title="限制项明细"
|
||||
content={
|
||||
<div
|
||||
style={{
|
||||
width: 380,
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{RULE_GROUPS.map((group, groupIndex) => {
|
||||
const items = row.items.filter(
|
||||
(item) => item.rule_group === group,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
style={{
|
||||
paddingBlock: 8,
|
||||
borderTop:
|
||||
groupIndex === 0 ? undefined : '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: items.length ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
<Text strong>{group}</Text>
|
||||
<Text type="secondary">{items.length} 项</Text>
|
||||
</div>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
minHeight: 30,
|
||||
paddingLeft: 12,
|
||||
}}
|
||||
>
|
||||
<Text>{item.rule_label}</Text>
|
||||
<Tag
|
||||
color={STATUS_META[item.status].color}
|
||||
style={{ flexShrink: 0, marginInlineEnd: 0 }}
|
||||
>
|
||||
{STATUS_META[item.status].label}
|
||||
</Tag>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary" style={{ paddingLeft: 12 }}>
|
||||
暂无配置
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px 18px',
|
||||
cursor: 'help',
|
||||
}}
|
||||
>
|
||||
{RULE_GROUPS.map((group) => {
|
||||
const count = groupCounts[group] || 0;
|
||||
return (
|
||||
<span
|
||||
key={group}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
padding: '0 6px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
color: 'rgba(0, 0, 0, 0.65)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '有效期',
|
||||
key: 'time',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{showTime(row.starts_at)}</Text>
|
||||
<Text type="secondary">至 {showTime(row.expires_at)}</Text>
|
||||
</Space>
|
||||
),
|
||||
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 (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{showTime(startsAt[0])}</Text>
|
||||
<Text type="secondary">至 {showTime(expiresAt[0])}</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>共 {row.total_rules} 条配置</Text>
|
||||
<Text type="secondary">
|
||||
最早失效 {showTime(expiresAt[0] || null)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
最晚失效 {showTime(expiresAt.at(-1) || null)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 115,
|
||||
render: (_, row) => (
|
||||
<Tag color={STATUS_META[row.status].color}>
|
||||
{STATUS_META[row.status].label}
|
||||
</Tag>
|
||||
),
|
||||
width: 190,
|
||||
render: (_, row) => {
|
||||
const counts = row.items.reduce<
|
||||
Partial<Record<LimitOverride['status'], number>>
|
||||
>((result, item) => {
|
||||
result[item.status] = (result[item.status] || 0) + 1;
|
||||
return result;
|
||||
}, {});
|
||||
return (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{Object.entries(STATUS_META).map(([status, meta]) =>
|
||||
counts[status as LimitOverride['status']] ? (
|
||||
<Tag key={status} color={meta.color}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
) : null,
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 ellipsis={{ tooltip: reasons.join(';') }}>{text}</Text>;
|
||||
},
|
||||
},
|
||||
{ title: '原因', dataIndex: 'reason', ellipsis: true, render: (v) => v || '-' },
|
||||
{
|
||||
title: '启用',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={row.enabled}
|
||||
disabled={row.mode === 'override'}
|
||||
onChange={(checked) => void updateEnabled(row, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 210,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
{row.mode !== 'override' && (
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{row.enabled && (
|
||||
<Popconfirm
|
||||
title="恢复全局策略?"
|
||||
description="将停用这条白名单例外;历史流水、配置及审计记录都会保留。"
|
||||
onConfirm={() => void restoreGlobal(row)}
|
||||
>
|
||||
<Button type="link" icon={<UndoOutlined />}>
|
||||
恢复全局
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="删除这条配置?"
|
||||
onConfirm={() => void deleteRow(row)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
width: 90,
|
||||
render: (_, row) => {
|
||||
const subjectKey = `${row.subject_type}:${row.subject_value}`;
|
||||
return (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={row.items.every((item) => item.enabled)}
|
||||
loading={togglingSubject === subjectKey}
|
||||
onChange={(enabled) => void toggleSubject(row, enabled)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space
|
||||
align="start"
|
||||
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}
|
||||
>
|
||||
<Space align="start" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 4 }}>
|
||||
白名单
|
||||
@@ -557,24 +767,8 @@ export default function LimitWhitelistPage() {
|
||||
统一管理限制全局值,并按手机号或设备设置临时白名单。
|
||||
</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增白名单
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
style={{ marginBottom: 16 }}
|
||||
message="规则说明"
|
||||
description="新增白名单后,普通限制项自动临时不限,风控限制项自动免告警;必须设置失效时间。“恢复全局”会停用白名单并立即重新使用全局限制。"
|
||||
/>
|
||||
|
||||
<Card title="全局限制" style={{ marginBottom: 16 }}>
|
||||
<Tabs
|
||||
activeKey={globalGroup}
|
||||
@@ -596,7 +790,19 @@ export default function LimitWhitelistPage() {
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={`白名单配置(${total})`}
|
||||
title={
|
||||
<Space size={12}>
|
||||
<span>{`白名单配置(${total} 个主体)`}</span>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增白名单
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Select
|
||||
@@ -604,10 +810,7 @@ export default function LimitWhitelistPage() {
|
||||
placeholder="主体类型"
|
||||
style={{ width: 120 }}
|
||||
value={subjectType}
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setSubjectType(value);
|
||||
}}
|
||||
onChange={setSubjectType}
|
||||
options={[
|
||||
{ value: 'phone', label: '手机号' },
|
||||
{ value: 'device', label: '设备' },
|
||||
@@ -620,45 +823,43 @@ export default function LimitWhitelistPage() {
|
||||
placeholder="限制项"
|
||||
style={{ width: 240 }}
|
||||
value={ruleFilter}
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setRuleFilter(value);
|
||||
}}
|
||||
onChange={setRuleFilter}
|
||||
options={rules.map((rule) => ({
|
||||
value: rule.code,
|
||||
label: rule.label,
|
||||
}))}
|
||||
/>
|
||||
<Input.Search
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索手机号或设备"
|
||||
style={{ width: 240 }}
|
||||
value={keyword}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
onSearch={() => {
|
||||
setPage(1);
|
||||
void load();
|
||||
}}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onPressEnter={applyFilters}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={applyFilters}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table<LimitOverride>
|
||||
rowKey="id"
|
||||
<Table<LimitSubject>
|
||||
rowKey={(row) => `${row.subject_type}:${row.subject_value}`}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 1450 }}
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (count) => `共 ${count} 个主体`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(
|
||||
nextPageSize !== pageSize ? 1 : nextPage,
|
||||
@@ -728,13 +929,51 @@ export default function LimitWhitelistPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{effectiveSubjectType === 'phone' && (
|
||||
<Form.Item
|
||||
name="subject_value"
|
||||
label="手机号"
|
||||
rules={[{ required: true, message: '请输入手机号' }]}
|
||||
>
|
||||
<Input
|
||||
disabled={!!editing}
|
||||
maxLength={20}
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="valid_range"
|
||||
label="有效期"
|
||||
rules={[
|
||||
{ required: true, message: '请选择生效和结束时间' },
|
||||
{
|
||||
validator: async (_, value?: [Dayjs, Dayjs]) => {
|
||||
if (!value?.[0] || !value?.[1]) {
|
||||
throw new Error('请选择完整的生效和结束时间');
|
||||
}
|
||||
if (value[1].valueOf() <= value[0].valueOf()) {
|
||||
throw new Error('结束时间必须晚于生效时间');
|
||||
}
|
||||
if (value[1].valueOf() <= dayjs().valueOf()) {
|
||||
throw new Error('结束时间必须晚于当前时间');
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['生效时间', '结束时间']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="rule_codes"
|
||||
label="限制项"
|
||||
rules={[{ required: true, message: '请选择限制项' }]}
|
||||
>
|
||||
<Checkbox.Group
|
||||
disabled={!!editing}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(values) => {
|
||||
if (watchedSubjectType === 'device') {
|
||||
@@ -746,8 +985,10 @@ export default function LimitWhitelistPage() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('subject_value', undefined);
|
||||
setDeviceCandidates([]);
|
||||
if (!editing) {
|
||||
form.setFieldValue('subject_value', undefined);
|
||||
setDeviceCandidates([]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -798,13 +1039,10 @@ export default function LimitWhitelistPage() {
|
||||
/>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
{effectiveSubjectType === 'device' && (
|
||||
<Form.Item
|
||||
label={watchedSubjectType === 'device' ? '选择设备' : '手机号'}
|
||||
extra={
|
||||
watchedSubjectType === 'device'
|
||||
? '可按手机号、用户 ID、设备型号或设备 ID 搜索;候选设备按最近活跃时间排序。'
|
||||
: undefined
|
||||
}
|
||||
label="选择设备"
|
||||
extra="可按手机号、用户 ID、设备型号或设备 ID 搜索;候选设备按最近活跃时间排序。"
|
||||
>
|
||||
<Form.Item
|
||||
name="subject_value"
|
||||
@@ -812,16 +1050,13 @@ export default function LimitWhitelistPage() {
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message:
|
||||
watchedSubjectType === 'device'
|
||||
? '请选择设备'
|
||||
: '请输入手机号',
|
||||
message: '请选择设备',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{editing ? (
|
||||
<Input disabled maxLength={128} />
|
||||
) : watchedSubjectType === 'device' ? (
|
||||
) : (
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
@@ -918,8 +1153,6 @@ export default function LimitWhitelistPage() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input maxLength={20} placeholder="请输入手机号" />
|
||||
)}
|
||||
</Form.Item>
|
||||
{!editing &&
|
||||
@@ -959,33 +1192,13 @@ export default function LimitWhitelistPage() {
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="starts_at" label="生效时间">
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="立即生效" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expires_at"
|
||||
label="失效时间"
|
||||
rules={[
|
||||
{ required: true, message: '请选择失效时间' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (value && value.valueOf() <= dayjs().valueOf()) {
|
||||
throw new Error('失效时间必须晚于当前时间');
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择失效时间"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="配置原因">
|
||||
)}
|
||||
{editing && (
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="reason" label="备注">
|
||||
<Input.TextArea maxLength={256} showCount rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -957,3 +957,18 @@ export interface LimitOverrideList {
|
||||
items: LimitOverride[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LimitSubject {
|
||||
subject_type: LimitSubjectType;
|
||||
subject_value: string;
|
||||
group_counts: Record<string, number>;
|
||||
total_rules: number;
|
||||
items: LimitOverride[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LimitSubjectList {
|
||||
items: LimitSubject[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user