353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|