优化:完善白名单配置交互与展示

This commit is contained in:
linkeyu
2026-07-30 20:21:20 +08:00
parent 4d163c7676
commit e98402d75c
2 changed files with 478 additions and 250 deletions
+463 -250
View File
@@ -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>
+15
View File
@@ -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;
}