feat: filter SMS routes by channel sensitive words

This commit is contained in:
hectorzhao
2026-09-10 15:50:56 +08:00
parent 86947827cc
commit 8e4bc5a20e
26 changed files with 1615 additions and 41 deletions
+101 -22
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { ChannelSensitiveWordsPanel } from './sensitive-words/ChannelSensitiveWordsPanel';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -31,6 +32,36 @@ const levelOptions = [
];
export function AdminSensitiveWordsPage() {
const [tab, setTab] = useState('platform');
const [channelVisited, setChannelVisited] = useState(false);
return (
<section className="page-stack admin-security-page">
<div className="page-heading">
<div>
<Breadcrumb items={['安全控制', '敏感词管理']} />
<h1></h1>
</div>
</div>
<Tabs
value={tab}
onChange={(value) => {
setTab(value);
if (value === 'channel') setChannelVisited(true);
}}
items={[
{ label: '平台敏感词', value: 'platform', content: null },
{ label: '通道敏感词', value: 'channel', content: null },
]}
/>
<div hidden={tab !== 'platform'}>
<PlatformSensitiveWordsPanel />
</div>
<div hidden={tab !== 'channel'}>{channelVisited ? <ChannelSensitiveWordsPanel /> : null}</div>
</section>
);
}
function PlatformSensitiveWordsPanel() {
const [items, setItems] = useState<SensitiveWordItem[]>([]);
const [keyword, setKeyword] = useState('');
const [word, setWord] = useState('');
@@ -39,7 +70,8 @@ export function AdminSensitiveWordsPage() {
const [error, setError] = useState('');
function loadData() {
adminApi.listSensitiveWords({ keyword })
adminApi
.listSensitiveWords({ keyword })
.then((data) => {
setItems(data as SensitiveWordItem[]);
setError('');
@@ -51,15 +83,37 @@ export function AdminSensitiveWordsPage() {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}), [items, keyword]);
const filteredItems = useMemo(
() =>
items.filter((item) => {
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}),
[items, keyword],
);
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
const columns: Array<TableColumn<SensitiveWordItem>> = [
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{
key: 'level',
title: '级别',
width: '120px',
render: (record) => (
<Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>
{levelLabelMap[record.level ?? 'medium'] ?? record.level}
</Tag>
),
},
{
key: 'status',
title: '状态',
width: '120px',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'neutral'}>
{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}
</Tag>
),
},
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{
key: 'actions',
@@ -67,15 +121,26 @@ export function AdminSensitiveWordsPage() {
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteSensitiveWord(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
<Button
icon={<Trash2 size={15} />}
onClick={() =>
adminApi
.deleteSensitiveWord(record.id)
.then(loadData)
.catch((failure: Error) => setError(failure.message))
}
size="sm"
variant="danger"
>
</Button>
),
},
], []);
];
function addItem() {
adminApi.createSensitiveWord({ word, level, status: 'active' })
adminApi
.createSensitiveWord({ word, level, status: 'active' })
.then(() => {
setWord('');
setLevel('medium');
@@ -89,10 +154,11 @@ export function AdminSensitiveWordsPage() {
<section className="page-stack admin-security-page">
<div className="page-heading">
<div>
<Breadcrumb items={['安全控制', '敏感词管理']} />
<h1></h1>
<h2></h2>
</div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -105,8 +171,12 @@ export function AdminSensitiveWordsPage() {
value={keyword}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={loadData}>
</Button>
<Button onClick={() => setKeyword('')} variant="ghost">
</Button>
</div>
</div>
@@ -115,19 +185,28 @@ export function AdminSensitiveWordsPage() {
</div>
<Modal
footer={(
footer={
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!word} onClick={addItem}></Button>
<Button onClick={() => setModalOpen(false)} variant="ghost">
</Button>
<Button disabled={!word} onClick={addItem}>
</Button>
</>
)}
}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加敏感词"
>
<div className="admin-security-form">
<Input label="敏感词" onChange={(event) => setWord(event.target.value)} placeholder="请输入敏感词" value={word} />
<Input
label="敏感词"
onChange={(event) => setWord(event.target.value)}
placeholder="请输入敏感词"
value={word}
/>
<Select
label="风险级别"
onChange={(event) => setLevel(event.target.value)}
@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelSensitiveWordsPanel } from './ChannelSensitiveWordsPanel';
const { api, channels } = vi.hoisted(() => ({
api: { list: vi.fn(), save: vi.fn(), remove: vi.fn() },
channels: vi.fn(),
}));
vi.mock('@/api/admin/channel-sensitive-words.api', () => ({ channelWordsApi: api }));
vi.mock('@/api/adminApi', () => ({ adminApi: { listChannels: channels } }));
const item = {
id: 'rule',
channelId: 'a',
word: '贷款',
status: 'active',
remark: '',
version: 4,
updatedAt: '2026-09-10T00:00:00Z',
channel: { id: 'a', name: '通道A', status: 'active' },
};
describe('channel word panel', () => {
beforeEach(() => {
vi.resetAllMocks();
channels.mockResolvedValue([{ id: 'a', name: '通道A', status: 'active' }]);
api.list.mockResolvedValue({ items: [item], total: 1 });
});
it('keeps save conflicts inside the dialog and does not dismiss on Escape or backdrop', async () => {
api.save.mockRejectedValue(Error('规则已被修改,请刷新后重试'));
render(<ChannelSensitiveWordsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '编辑' }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByRole('textbox', { name: /敏感词/ }), { target: { value: '理财' } });
fireEvent.keyDown(dialog, { key: 'Escape' });
fireEvent.mouseDown(document.querySelector('.ui-modal-backdrop') ?? document.body);
expect(dialog).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('规则已被修改');
expect(api.save).toHaveBeenCalledWith({ channelId: 'a', word: '理财', status: 'active', remark: '' }, item);
expect(api.list).toHaveBeenCalledTimes(1);
});
it('only deletes after confirmation and keeps failures visible', async () => {
api.remove.mockRejectedValue(Error('删除失败'));
render(<ChannelSensitiveWordsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '删除' }));
expect(api.remove).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('删除失败');
expect(api.remove).toHaveBeenCalledWith(item);
});
it('keeps keyword and status separate and uses server pagination', async () => {
render(<ChannelSensitiveWordsPanel />);
await screen.findByRole('button', { name: '编辑' });
fireEvent.change(screen.getByLabelText('敏感词'), { target: { value: '理财' } });
fireEvent.click(screen.getByRole('button', { name: '启用状态' }));
fireEvent.click(screen.getByRole('option', { name: '停用' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(api.list).toHaveBeenLastCalledWith({
channelId: '',
keyword: '理财',
status: 'inactive',
page: 1,
pageSize: 25,
}),
);
});
});
@@ -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>
);
}
@@ -47,6 +47,29 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
}
>
<div className="admin-sms-send-detail">
<section aria-label="通道筛选原因">
<h3></h3>
{record.channelWordDecisions?.length ? (
record.channelWordDecisions.map((decision) => (
<div key={decision.id}>
<p>
{getTime(decision.decidedAt)} ·{' '}
{decision.snapshot.reason ||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
</p>
{decision.snapshot.hits.map((hit) => (
<p key={hit.channelId}>
{hit.channelName || hit.channelId} {hit.count}
{hit.samples.map((sample) => `${sample.word}`).join('、')}
{hit.count > hit.samples.length ? '(仅展示部分)' : ''}
</p>
))}
</div>
))
) : (
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
)}
</section>
<section aria-label="引流发送资格">
<h3></h3>
{record.drainageGate ? (