feat: filter SMS routes by channel sensitive words
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { request, withQuery } from '../core/httpClient';
|
||||
import type { PagedResult } from '../types';
|
||||
|
||||
export type ChannelWord = {
|
||||
id: string;
|
||||
channelId: string;
|
||||
word: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
channel: { id: string; name: string; status: string };
|
||||
};
|
||||
export type ChannelWordForm = { channelId: string; word: string; status: string; remark: string };
|
||||
export type ChannelWordQuery = { channelId: string; keyword: string; status: string; page: number; pageSize: number };
|
||||
const path = '/admin/dictionaries/channel-sensitive-words';
|
||||
export const channelWordsApi = {
|
||||
list: (query: ChannelWordQuery) => request<PagedResult<ChannelWord>>(withQuery(path, query)),
|
||||
save: (form: ChannelWordForm, item?: ChannelWord) =>
|
||||
request<ChannelWord>(item ? `${path}/${encodeURIComponent(item.id)}` : path, {
|
||||
method: item ? 'PATCH' : 'POST',
|
||||
body: JSON.stringify({ ...form, ...(item ? { version: item.version } : {}) }),
|
||||
}),
|
||||
remove: (item: ChannelWord) =>
|
||||
request<{ deleted: boolean }>(`${path}/${encodeURIComponent(item.id)}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ version: item.version }),
|
||||
}),
|
||||
};
|
||||
@@ -118,6 +118,20 @@ export type SendQualityResponse = {
|
||||
};
|
||||
|
||||
export type SmsMessageRecord = {
|
||||
channelWordDecisions?: Array<{
|
||||
id: string;
|
||||
decidedAt: string;
|
||||
snapshot: {
|
||||
selectedChannelId: string | null;
|
||||
reason: string | null;
|
||||
hits: Array<{
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
count: number;
|
||||
samples: Array<{ id: string; word: string; version: number }>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user