feat: filter SMS routes by channel sensitive words
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
||||
import {
|
||||
channelWordsApi,
|
||||
type ChannelWord,
|
||||
type ChannelWordForm,
|
||||
type ChannelWordQuery,
|
||||
} from '@/api/admin/channel-sensitive-words.api';
|
||||
import { Button, Input, Modal, Pagination, QueryPanel, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const emptyForm: ChannelWordForm = { channelId: '', word: '', status: 'active', remark: '' };
|
||||
const initialQuery: ChannelWordQuery = { channelId: '', keyword: '', status: 'all', page: 1, pageSize: 25 };
|
||||
const statuses = [
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '停用', value: 'inactive' },
|
||||
];
|
||||
export function ChannelSensitiveWordsPanel() {
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [filters, setFilters] = useState(initialQuery);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [items, setItems] = useState<ChannelWord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [channelError, setChannelError] = useState('');
|
||||
const [reload, setReload] = useState(0);
|
||||
const [editor, setEditor] = useState<{ item?: ChannelWord; initial: ChannelWordForm } | null>(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [removing, setRemoving] = useState<ChannelWord | null>(null);
|
||||
const [removeError, setRemoveError] = useState('');
|
||||
const busy = useRef(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
adminApi
|
||||
.listChannels()
|
||||
.then((data) => {
|
||||
if (alive) {
|
||||
setChannels(data);
|
||||
setChannelError('');
|
||||
}
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
if (alive) setChannelError(failure.message || '通道加载失败');
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [reload]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
channelWordsApi
|
||||
.list(query)
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
if (query.page > 1 && !data.items.length) {
|
||||
setQuery({ ...query, page: Math.max(1, Math.ceil(data.total / query.pageSize)) });
|
||||
return;
|
||||
}
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
if (alive) {
|
||||
setError(failure.message || '通道敏感词加载失败');
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [query, reload]);
|
||||
const channelOptions = channels
|
||||
.filter((channel) => channel.status !== 'deleted')
|
||||
.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: `${channel.name}${channel.status === 'active' ? '' : '(已停用)'}`,
|
||||
}));
|
||||
if (editor?.item && !channelOptions.some((option) => option.value === editor.item!.channelId))
|
||||
channelOptions.push({ value: editor.item.channelId, label: `${editor.item.channel.name}(历史通道)` });
|
||||
function edit(item?: ChannelWord) {
|
||||
const initial = item
|
||||
? { channelId: item.channelId, word: item.word, status: item.status, remark: item.remark }
|
||||
: { ...emptyForm };
|
||||
setEditor({ item, initial });
|
||||
setForm(initial);
|
||||
setFormError('');
|
||||
}
|
||||
async function save() {
|
||||
if (busy.current || !editor) return;
|
||||
if (!form.channelId || !form.word.trim()) {
|
||||
setFormError('请选择通道并填写敏感词');
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
setSaving(true);
|
||||
setFormError('');
|
||||
try {
|
||||
await channelWordsApi.save(form, editor.item);
|
||||
setEditor(null);
|
||||
setReload((value) => value + 1);
|
||||
} catch (failure) {
|
||||
setFormError(failure instanceof Error ? failure.message : '保存失败');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
async function toggle(item: ChannelWord) {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await channelWordsApi.save(
|
||||
{
|
||||
channelId: item.channelId,
|
||||
word: item.word,
|
||||
remark: item.remark,
|
||||
status: item.status === 'active' ? 'inactive' : 'active',
|
||||
},
|
||||
item,
|
||||
);
|
||||
setReload((value) => value + 1);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '操作失败');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (busy.current || !removing) return;
|
||||
busy.current = true;
|
||||
setSaving(true);
|
||||
setRemoveError('');
|
||||
try {
|
||||
await channelWordsApi.remove(removing);
|
||||
setRemoving(null);
|
||||
setReload((value) => value + 1);
|
||||
} catch (failure) {
|
||||
setRemoveError(failure instanceof Error ? failure.message : '删除失败');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
const columns: Array<TableColumn<ChannelWord>> = [
|
||||
{
|
||||
key: 'channel',
|
||||
title: '通道',
|
||||
render: (item) => `${item.channel.name}${item.channel.status === 'active' ? '' : '(已停用或删除)'}`,
|
||||
},
|
||||
{ key: 'word', title: '敏感词', render: (item) => item.word },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (item) => (
|
||||
<Tag tone={item.status === 'active' ? 'success' : 'neutral'}>{item.status === 'active' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{ key: 'remark', title: '备注', render: (item) => item.remark || '-' },
|
||||
{ key: 'updatedAt', title: '更新时间', render: (item) => formatDateTime(item.updatedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
render: (item) => (
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button size="sm" disabled={saving} onClick={() => edit(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void toggle(item)}>
|
||||
{item.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setRemoving(item);
|
||||
setRemoveError('');
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="page-heading">
|
||||
<p>命中启用词的短信不走对应通道。已完成选路的消息不受后续配置变更影响。</p>
|
||||
<Button onClick={() => edit()} disabled={Boolean(channelError)}>
|
||||
新增通道敏感词
|
||||
</Button>
|
||||
</div>
|
||||
<QueryPanel title="筛选通道敏感词">
|
||||
<Select
|
||||
label="通道"
|
||||
searchable
|
||||
options={[{ label: '全部通道', value: '' }, ...channelOptions]}
|
||||
value={filters.channelId}
|
||||
onChange={(event) => setFilters({ ...filters, channelId: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="敏感词"
|
||||
value={filters.keyword}
|
||||
maxLength={200}
|
||||
onChange={(event) => setFilters({ ...filters, keyword: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="启用状态"
|
||||
options={[{ label: '全部状态', value: 'all' }, ...statuses]}
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters({ ...filters, status: event.target.value })}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button onClick={() => setQuery({ ...filters, page: 1, pageSize: query.pageSize })}>查询</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setFilters(initialQuery);
|
||||
setQuery({ ...initialQuery });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setReload((value) => value + 1)}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
{error || channelError ? (
|
||||
<p className="form-error" role="alert">
|
||||
{error || channelError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="surface admin-security-table-card" aria-busy={loading}>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={loading ? [] : items}
|
||||
rowKey="id"
|
||||
emptyText={loading ? '加载中…' : error ? '加载失败,请重试' : '暂无通道敏感词'}
|
||||
/>
|
||||
<Pagination
|
||||
total={total}
|
||||
page={query.page}
|
||||
pageSize={query.pageSize}
|
||||
previousDisabled={loading || query.page <= 1}
|
||||
nextDisabled={loading || query.page * query.pageSize >= total}
|
||||
onPrevious={() => setQuery({ ...query, page: query.page - 1 })}
|
||||
onNext={() => setQuery({ ...query, page: query.page + 1 })}
|
||||
onPageChange={(page) => setQuery({ ...query, page })}
|
||||
onPageSizeChange={(pageSize) => setQuery({ ...query, pageSize, page: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
open={Boolean(editor)}
|
||||
title={editor?.item ? '编辑通道敏感词' : '新增通道敏感词'}
|
||||
dirty={Boolean(editor && JSON.stringify(form) !== JSON.stringify(editor.initial))}
|
||||
onClose={() => {
|
||||
if (!saving) setEditor(null);
|
||||
}}
|
||||
footer={({ requestClose }) => (
|
||||
<>
|
||||
<Button variant="ghost" disabled={saving} onClick={requestClose}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={() => void save()}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
{formError ? (
|
||||
<p className="form-error" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
<Select
|
||||
label="通道"
|
||||
searchable
|
||||
required
|
||||
value={form.channelId}
|
||||
disabled={saving}
|
||||
options={[{ label: '请选择通道', value: '' }, ...channelOptions]}
|
||||
onChange={(event) => setForm({ ...form, channelId: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="敏感词"
|
||||
required
|
||||
maxLength={200}
|
||||
disabled={saving}
|
||||
value={form.word}
|
||||
hint="按原文连续匹配,区分英文大小写"
|
||||
onChange={(event) => setForm({ ...form, word: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="启用状态"
|
||||
options={statuses}
|
||||
disabled={saving}
|
||||
value={form.status}
|
||||
onChange={(event) => setForm({ ...form, status: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="备注"
|
||||
maxLength={500}
|
||||
disabled={saving}
|
||||
value={form.remark}
|
||||
onChange={(event) => setForm({ ...form, remark: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(removing)}
|
||||
title="删除通道敏感词"
|
||||
onClose={() => {
|
||||
if (!saving) setRemoving(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" disabled={saving} onClick={() => setRemoving(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button variant="danger" disabled={saving} onClick={() => void remove()}>
|
||||
确认删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
确认删除 {removing?.channel.name} 的敏感词“{removing?.word}”?历史筛选记录会保留。
|
||||
</p>
|
||||
{removeError ? (
|
||||
<p className="form-error" role="alert">
|
||||
{removeError}
|
||||
</p>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user