Files
lislgosms/src/apps/admin/sensitive-words/ChannelSensitiveWordsPanel.tsx
T

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>
);
}