imeeting/frontend/src/pages/business/HotWords.tsx

591 lines
19 KiB
TypeScript
Raw Normal View History

2026-03-10 09:43:33 +00:00
import React, { useEffect, useMemo, useState } from "react";
import {
App,
Badge,
Button,
Card,
Col,
Form,
Input,
InputNumber,
List,
Modal,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
2026-03-10 09:43:33 +00:00
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
import {
deleteHotWord,
getHotWordPage,
getPinyinSuggestion,
2026-03-10 09:43:33 +00:00
saveHotWord,
updateHotWord,
type HotWordVO,
} from "../../api/business/hotword";
import {
deleteHotWordGroup,
getHotWordGroupOptions,
getHotWordGroupPage,
saveHotWordGroup,
updateHotWordGroup,
type HotWordGroupVO,
} from "../../api/business/hotwordGroup";
import AppPagination from "../../components/shared/AppPagination";
import ListActionBar from "../../components/shared/ListActionBar/ListActionBar";
const { Option } = Select;
const { Text } = Typography;
2026-03-10 09:43:33 +00:00
type HotWordFormValues = {
word: string;
pinyin?: string;
category?: string;
hotWordGroupId?: number;
2026-03-10 09:43:33 +00:00
weight: number;
status: number;
remark?: string;
};
type HotWordGroupFormValues = {
groupName: string;
status: number;
2026-03-10 09:43:33 +00:00
remark?: string;
};
const HotWords: React.FC = () => {
const { message } = App.useApp();
const { t } = useTranslation();
2026-03-10 09:43:33 +00:00
const [form] = Form.useForm<HotWordFormValues>();
const [groupForm] = Form.useForm<HotWordGroupFormValues>();
2026-03-10 09:43:33 +00:00
const { items: categories } = useDict("biz_hotword_category");
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
const [loading, setLoading] = useState(false);
const [data, setData] = useState<HotWordVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [size, setSize] = useState(10);
2026-03-10 09:43:33 +00:00
const [searchWord, setSearchWord] = useState("");
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [searchGroupId, setSearchGroupId] = useState<number | undefined>(undefined);
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
const [groupEditorVisible, setGroupEditorVisible] = useState(false);
const [groupLoading, setGroupLoading] = useState(false);
const [groupSubmitLoading, setGroupSubmitLoading] = useState(false);
const [groupData, setGroupData] = useState<HotWordGroupVO[]>([]);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [filterVisible, setFilterVisible] = useState(false);
const groupNameMap = useMemo(
() => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record<number, string>,
[groupOptions]
);
useEffect(() => {
2026-03-10 09:43:33 +00:00
void fetchData();
}, [current, searchCategory, searchGroupId, size]);
useEffect(() => {
void loadGroupOptions();
void loadGroupPage();
}, []);
const fetchData = async () => {
setLoading(true);
try {
2026-03-10 09:43:33 +00:00
const res = await getHotWordPage({
current,
size,
word: searchWord,
category: searchCategory,
hotWordGroupId: searchGroupId,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
2026-03-10 09:43:33 +00:00
if (res.data?.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} finally {
setLoading(false);
}
};
const loadGroupOptions = async () => {
const res = await getHotWordGroupOptions(isPlatformAdmin ? activeTenantId : undefined);
setGroupOptions(res.data?.data || []);
};
const loadGroupPage = async () => {
setGroupLoading(true);
try {
const res = await getHotWordGroupPage({ current: 1, size: 200 });
if (isPlatformAdmin) {
const scoped = await getHotWordGroupPage({ current: 1, size: 200, tenantId: activeTenantId });
setGroupData(scoped.data?.data?.records || []);
return;
}
setGroupData(res.data?.data?.records || []);
} finally {
setGroupLoading(false);
2026-03-10 09:43:33 +00:00
}
};
2026-03-10 09:43:33 +00:00
const handleOpenModal = (record?: HotWordVO) => {
if (record) {
setEditingId(record.id);
form.setFieldsValue({
2026-03-10 09:43:33 +00:00
word: record.word,
pinyin: record.pinyinList?.[0] || "",
category: record.category,
hotWordGroupId: record.hotWordGroupId,
2026-03-10 09:43:33 +00:00
weight: record.weight,
status: record.status,
remark: record.remark,
});
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ weight: 2, status: 1, hotWordGroupId: searchGroupId });
}
setModalVisible(true);
};
2026-03-10 09:43:33 +00:00
const handleDelete = async (id: number) => {
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
await loadGroupPage();
2026-03-10 09:43:33 +00:00
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
2026-03-10 09:43:33 +00:00
const payload = {
...values,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
2026-03-10 09:43:33 +00:00
matchStrategy: 1,
pinyinList: values.pinyin ? [values.pinyin.trim()] : [],
};
if (editingId) {
await updateHotWord({ ...payload, id: editingId });
2026-03-10 09:43:33 +00:00
message.success("更新成功");
} else {
await saveHotWord(payload);
2026-03-10 09:43:33 +00:00
message.success("新增成功");
}
setModalVisible(false);
await Promise.all([fetchData(), loadGroupOptions(), loadGroupPage()]);
} finally {
setSubmitLoading(false);
}
};
const handleWordBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
2026-03-10 09:43:33 +00:00
const word = e.target.value?.trim();
if (!word || form.getFieldValue("pinyin")) {
return;
}
try {
const res = await getPinyinSuggestion(word);
const firstPinyin = res.data?.data?.[0];
if (firstPinyin) {
form.setFieldValue("pinyin", firstPinyin);
}
2026-03-10 09:43:33 +00:00
} catch {
// handled by interceptor
}
};
const openGroupEditor = (record?: HotWordGroupVO, e?: React.MouseEvent) => {
e?.stopPropagation();
if (record) {
setEditingGroupId(record.id);
groupForm.setFieldsValue({
groupName: record.groupName,
status: record.status,
remark: record.remark,
});
} else {
setEditingGroupId(null);
groupForm.resetFields();
groupForm.setFieldsValue({ status: 1 });
}
setGroupEditorVisible(true);
};
const handleGroupSubmit = async () => {
try {
const values = await groupForm.validateFields();
setGroupSubmitLoading(true);
if (editingGroupId) {
await updateHotWordGroup({ ...values, id: editingGroupId, tenantId: isPlatformAdmin ? activeTenantId : undefined });
message.success("热词组更新成功");
} else {
await saveHotWordGroup({ ...values, tenantId: isPlatformAdmin ? activeTenantId : undefined });
message.success("热词组创建成功");
}
setGroupEditorVisible(false);
await Promise.all([loadGroupOptions(), loadGroupPage()]);
} finally {
setGroupSubmitLoading(false);
}
};
const handleDeleteGroup = async (id: number, e?: React.MouseEvent) => {
e?.stopPropagation();
await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined);
message.success("热词组删除成功");
if (searchGroupId === id) {
setSearchGroupId(undefined);
}
await Promise.all([loadGroupOptions(), loadGroupPage(), fetchData()]);
};
const columns = [
{
2026-03-10 09:43:33 +00:00
title: "热词原文",
dataIndex: "word",
key: "word",
ellipsis: true,
render: (text: string) => <Text strong ellipsis={{ tooltip: text }}>{text}</Text>,
},
{
2026-03-10 09:43:33 +00:00
title: "拼音",
dataIndex: "pinyinList",
key: "pinyinList",
render: (list: string[]) => list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
},
{
title: "分类",
2026-03-10 09:43:33 +00:00
dataIndex: "category",
key: "category",
render: (value: string) => categories.find((item) => item.itemValue === value)?.itemLabel || value || "-",
},
{
title: "热词组",
dataIndex: "hotWordGroupId",
key: "hotWordGroupId",
render: (value?: number, record?: HotWordVO) => {
const name = record?.hotWordGroupName || (value ? groupNameMap[value] : undefined);
return name ? <Tag color="blue">{name}</Tag> : <Text type="secondary"></Text>;
},
},
{
2026-03-10 09:43:33 +00:00
title: "权重",
dataIndex: "weight",
key: "weight",
render: (value: number) => <Tag color="orange">{value}</Tag>,
},
{
2026-03-10 09:43:33 +00:00
title: "状态",
dataIndex: "status",
key: "status",
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
2026-03-10 09:43:33 +00:00
title: "操作",
key: "action",
render: (_: unknown, record: HotWordVO) => (
<Space size="middle">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
</Button>
<Popconfirm
title="确定删除这条热词吗?"
onConfirm={() => handleDelete(record.id)}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
2026-03-10 09:43:33 +00:00
const filterContent = (
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8 }}></div>
<Select
placeholder="按分类筛选"
style={{ width: '100%' }}
allowClear
value={searchCategory}
onChange={(value) => {
setSearchCategory(value);
setCurrent(1);
setFilterVisible(false);
}}
>
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</div>
);
return (
<div className="app-page" style={{ padding: '16px', display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', gap: '16px', flex: 1, minHeight: 0 }}>
{/* Left Panel: Hotword Groups */}
<Card
className="shadow-sm"
title="热词组"
style={{ width: '25%', display: 'flex', flexDirection: 'column', minWidth: 280 }}
styles={{ body: { padding: 0, flex: 1, overflow: 'auto' } }}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openGroupEditor()}
size="small"
/>
}
>
<List
loading={groupLoading}
dataSource={[{ id: undefined, groupName: '全部热词' } as any, ...groupData]}
renderItem={(item) => {
const isSelected = searchGroupId === item.id;
const actions = [];
if (item.id) {
actions.push(
<Button
type="text"
icon={<EditOutlined />}
onClick={(e) => openGroupEditor(item, e)}
size="small"
/>
);
actions.push(
<Popconfirm
title="确定删除这个热词组吗?"
description="删除前必须先解除模板引用并清空组内热词。"
onConfirm={(e) => handleDeleteGroup(item.id, e)}
onCancel={e => e?.stopPropagation()}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={e => e.stopPropagation()}
size="small"
/>
</Popconfirm>
);
}
return (
<List.Item
onClick={() => {
setSearchGroupId(item.id);
setCurrent(1);
}}
style={{
cursor: 'pointer',
padding: '12px 24px',
background: isSelected ? 'var(--ant-color-primary-bg, #e6f4ff)' : 'transparent',
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
transition: 'background 0.3s'
}}
actions={actions}
>
<List.Item.Meta
title={
<Text strong style={{ color: isSelected ? 'var(--ant-color-primary, #1677ff)' : 'inherit' }}>
{item.groupName}
</Text>
}
description={
item.id
? <><Tag color={item.hotWordCount >= 200 ? "red" : "processing"}>{item.hotWordCount}/200</Tag> {item.remark}</>
: '查看所有热词'
}
/>
</List.Item>
);
}}
/>
</Card>
{/* Right Panel: Hotwords */}
<Card
className="shadow-sm"
title={searchGroupId ? groupData.find(g => g.id === searchGroupId)?.groupName || '热词列表' : '全部热词'}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
<div style={{ padding: '16px 24px 0' }}>
<ListActionBar
actions={[
{
key: 'add',
label: '新增热词',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => handleOpenModal()
}
]}
search={{
placeholder: '搜索热词原文',
value: searchWord,
onChange: (val) => setSearchWord(val),
onSearch: () => {
setCurrent(1);
void fetchData();
}
2026-03-10 09:43:33 +00:00
}}
filter={{
content: filterContent,
title: '高级筛选',
visible: filterVisible,
onVisibleChange: setFilterVisible,
isActive: !!searchCategory,
selectedLabel: searchCategory ? categories.find(c => c.itemValue === searchCategory)?.itemLabel : '筛选'
}}
showRefresh
onRefresh={() => {
2026-03-10 09:43:33 +00:00
setCurrent(1);
void fetchData();
void loadGroupPage();
2026-03-10 09:43:33 +00:00
}}
/>
</div>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "16px 24px 0" }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: "max-content" }}
pagination={false}
/>
</div>
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
</Card>
</div>
2026-03-10 09:43:33 +00:00
<Modal
title={editingId ? "编辑热词" : "新增热词"}
open={modalVisible}
onOk={() => void handleSubmit()}
2026-03-10 09:43:33 +00:00
onCancel={() => setModalVisible(false)}
confirmLoading={submitLoading}
width={560}
destroyOnHidden
>
2026-03-10 09:43:33 +00:00
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: "请输入热词原文" }]}>
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
</Form.Item>
2026-03-10 09:43:33 +00:00
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="热词分类">
<Select placeholder="请选择分类" allowClear>
2026-03-10 09:43:33 +00:00
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="hotWordGroupId" label="所属热词组">
<Select placeholder="请选择热词组" allowClear options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
2026-03-10 09:43:33 +00:00
<Form.Item
name="weight"
label="识别权重 (1-5)"
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
>
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="使用状态">
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
2026-03-10 09:43:33 +00:00
<Input.TextArea rows={2} placeholder="记录热词来源或适用场景" />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingGroupId ? "编辑热词组" : "新增热词组"}
open={groupEditorVisible}
onCancel={() => setGroupEditorVisible(false)}
onOk={() => void handleGroupSubmit()}
confirmLoading={groupSubmitLoading}
destroyOnHidden
>
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="groupName" label="热词组名称" rules={[{ required: true, message: "请输入热词组名称" }]}>
<Input placeholder="例如:项目术语、客户名单" maxLength={100} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="说明这个热词组的适用范围" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default HotWords;