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

669 lines
22 KiB
TypeScript

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";
import PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import FormDrawer from "@/components/shared/FormDrawer";
import SectionCard from "@/components/shared/SectionCard";
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
import {
deleteHotWord,
getHotWordPage,
getPinyinSuggestion,
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 "./HotWords.css";
const { Option } = Select;
const { Text } = Typography;
type HotWordFormValues = {
word: string;
pinyin?: string;
category?: string;
hotWordGroupId?: number;
weight: number;
status: number;
remark?: string;
};
type HotWordGroupFormValues = {
groupName: string;
status: number;
remark?: string;
};
type GroupListItem = HotWordGroupVO | { id?: undefined; groupName: string; remark?: string; hotWordCount?: number; status?: number };
const HotWords: React.FC = () => {
const { message } = App.useApp();
const { t } = useTranslation();
const [form] = Form.useForm<HotWordFormValues>();
const [groupForm] = Form.useForm<HotWordGroupFormValues>();
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);
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 [groupTotal, setGroupTotal] = useState(0);
const [groupCurrent, setGroupCurrent] = useState(1);
const [groupSize, setGroupSize] = useState(8);
const [groupSearchInput, setGroupSearchInput] = useState("");
const [groupSearchName, setGroupSearchName] = useState("");
const [groupSearchStatus, setGroupSearchStatus] = useState<number | undefined>(undefined);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [selectedGroupName, setSelectedGroupName] = useState<string | undefined>(undefined);
const groupNameMap = useMemo(
() => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record<number, string>,
[groupOptions]
);
useEffect(() => {
void fetchData();
}, [current, searchCategory, searchGroupId, size]);
useEffect(() => {
void loadGroupOptions();
}, [isPlatformAdmin, activeTenantId]);
useEffect(() => {
void loadGroupPage();
}, [groupCurrent, groupSearchName, groupSearchStatus, groupSize, isPlatformAdmin, activeTenantId]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getHotWordPage({
current,
size,
word: searchWord,
category: searchCategory,
hotWordGroupId: searchGroupId,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
if (res.data?.data) {
setData(res.data.data.records || []);
setTotal(res.data.data.total || 0);
}
} 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: groupCurrent,
size: groupSize,
name: groupSearchName || undefined,
status: groupSearchStatus,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
setGroupData(res.data?.data?.records || []);
setGroupTotal(res.data?.data?.total || 0);
} finally {
setGroupLoading(false);
}
};
const reloadGroupList = async (resetToFirstPage = false) => {
await loadGroupOptions();
if (resetToFirstPage && groupCurrent !== 1) {
setGroupCurrent(1);
return;
}
await loadGroupPage();
};
const handleOpenModal = (record?: HotWordVO) => {
if (record) {
setEditingId(record.id);
form.setFieldsValue({
word: record.word,
pinyin: record.pinyinList?.[0] || "",
category: record.category,
hotWordGroupId: record.hotWordGroupId,
weight: record.weight,
status: record.status,
remark: record.remark,
});
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ weight: 2, status: 1, hotWordGroupId: searchGroupId });
}
setModalVisible(true);
};
const handleDelete = async (id: number) => {
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
await loadGroupPage();
};
const handleSubmit = async (formValues?: HotWordFormValues) => {
try {
const values = formValues ?? await form.validateFields();
setSubmitLoading(true);
const payload = {
...values,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
matchStrategy: 1,
pinyinList: values.pinyin ? [values.pinyin.trim()] : [],
};
if (editingId) {
await updateHotWord({ ...payload, id: editingId });
message.success("更新成功");
} else {
await saveHotWord(payload);
message.success("新增成功");
}
setModalVisible(false);
await Promise.all([fetchData(), loadGroupOptions(), loadGroupPage()]);
} finally {
setSubmitLoading(false);
}
};
const handleWordBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
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);
}
} 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 reloadGroupList(true);
} 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);
setSelectedGroupName(undefined);
}
await Promise.all([reloadGroupList(), fetchData()]);
};
const handleSelectGroup = (item: GroupListItem) => {
setSearchGroupId(item.id);
setSelectedGroupName(item.id ? item.groupName : undefined);
setCurrent(1);
};
const hotWordGroupTitle = searchGroupId
? selectedGroupName || groupData.find((item) => item.id === searchGroupId)?.groupName || groupNameMap[searchGroupId] || "热词列表"
: "全部热词";
const groupListData: GroupListItem[] = [{ id: undefined, groupName: "全部热词" }, ...groupData];
const columns = [
{
title: "热词原文",
dataIndex: "word",
key: "word",
width: 180,
ellipsis: true,
render: (text: string) => <Text strong ellipsis={{ tooltip: text }}>{text}</Text>,
},
{
title: "拼音",
dataIndex: "pinyinList",
key: "pinyinList",
width: 160,
render: (list: string[]) => list?.[0] ? <Tag className="hotwords-pinyin-tag">{list[0]}</Tag> : <Text type="secondary">-</Text>,
},
{
title: "分类",
dataIndex: "category",
key: "category",
width: 140,
render: (value: string) => categories.find((item) => item.itemValue === value)?.itemLabel || value || "-",
},
{
title: "热词组",
dataIndex: "hotWordGroupId",
key: "hotWordGroupId",
width: 160,
render: (value?: number, record?: HotWordVO) => {
const name = record?.hotWordGroupName || (value ? groupNameMap[value] : undefined);
return name ? <Tag color="blue">{name}</Tag> : <Text type="secondary"></Text>;
},
},
{
title: "权重",
dataIndex: "weight",
key: "weight",
width: 90,
render: (value: number) => <Tag color="orange">{value}</Tag>,
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
title: "操作",
key: "action",
width: 140,
fixed: "right" as const,
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>
),
},
];
return (
<PageContainer
title={null}
className="hotwords-page"
>
<SectionCard
title="热词管理"
description="管理 ASR 识别热词与热词组,提升特定场景下的转写识别准确率。"
>
<div className="hotwords-layout">
<Card
className="hotwords-group-panel"
title="热词组"
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openGroupEditor()}
size="small"
aria-label="新增热词组"
/>
}
>
<div className="hotwords-group-panel__filters">
<Space direction="vertical" size={12} className="hotwords-group-panel__filter-stack">
<Input.Search
placeholder="搜索热词组名称"
allowClear
value={groupSearchInput}
onChange={(e) => setGroupSearchInput(e.target.value)}
onSearch={(value) => {
setGroupSearchInput(value);
setGroupSearchName(value.trim());
setGroupCurrent(1);
}}
/>
<Select
placeholder="按状态筛选"
allowClear
value={groupSearchStatus}
options={[
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
]}
onChange={(value) => {
setGroupSearchStatus(value);
setGroupCurrent(1);
}}
/>
</Space>
</div>
<div className="hotwords-group-panel__list">
<List
loading={groupLoading}
dataSource={groupListData}
renderItem={(item) => {
const isSelected = searchGroupId === item.id;
const actions = [];
if (item.id) {
actions.push(
<Button
key={`edit-${item.id}`}
type="text"
icon={<EditOutlined />}
onClick={(e) => openGroupEditor(item, e)}
size="small"
/>
);
actions.push(
<Popconfirm
key={`delete-${item.id}`}
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={() => handleSelectGroup(item)}
className={isSelected ? "hotwords-group-item is-selected" : "hotwords-group-item"}
actions={actions}
>
<List.Item.Meta
title={
<Text strong className="hotwords-group-item__title">
{item.groupName}
</Text>
}
description={
item.id
? (
<span className="hotwords-group-item__desc">
<Tag color={item.hotWordCount >= 200 ? "red" : item.status === 1 ? "processing" : "default"}>
{item.hotWordCount}/200
</Tag>
<span>{item.remark || "暂无备注"}</span>
</span>
)
: "查看所有热词"
}
/>
</List.Item>
);
}}
/>
</div>
<AppPagination
className="hotwords-group-pagination"
simple
showSizeChanger={false}
showQuickJumper={false}
current={groupCurrent}
pageSize={groupSize}
total={groupTotal}
onChange={(page, pageSize) => {
setGroupCurrent(page);
setGroupSize(pageSize);
}}
/>
</Card>
<DataListPanel
className="hotwords-list-panel"
leftActions={<Text strong>{hotWordGroupTitle}</Text>}
rightActions={
<Space wrap size="small">
<Input
placeholder="搜索热词原文"
prefix={<SearchOutlined />}
allowClear
value={searchWord}
onChange={(e) => setSearchWord(e.target.value)}
onPressEnter={() => { setCurrent(1); void fetchData(); }}
className="hotwords-search__word"
/>
<Select
placeholder="筛选分类"
allowClear
value={searchCategory || undefined}
onChange={(value) => {
setSearchCategory(value as string);
setCurrent(1);
void fetchData();
}}
className="hotwords-search__category"
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }} title="刷新" aria-label="刷新" />
</Space>
}
footer={
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
}
>
<Table
className="hotwords-table"
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: "max(100%, 1000px)", y: "100%" }}
pagination={false}
/>
</DataListPanel>
</div>
</SectionCard>
<FormDrawer
title={editingId ? "编辑热词" : "新增热词"}
subtitle={editingId ? "调整热词分类、分组、权重和启用状态" : "补充识别热词,提升会议转写命中率"}
open={modalVisible}
onClose={() => setModalVisible(false)}
size="md"
bodyDensity="compact"
okText="确定"
okIcon={<SaveOutlined />}
okLoading={submitLoading}
okButtonProps={{ htmlType: "submit", form: "hotword-form" }}
>
<Form
id="hotword-form"
form={form}
layout="vertical"
className="hotwords-modal-form hotwords-drawer-form"
onFinish={(values) => void handleSubmit(values)}
>
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: "请输入热词原文" }]}>
<Input placeholder="输入识别关键词" maxLength={80} onBlur={handleWordBlur} />
</Form.Item>
<Form.Item name="pinyin" hidden>
<Input />
</Form.Item>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item name="category" label="热词分类">
<Select placeholder="请选择分类" allowClear>
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={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 xs={24} sm={12}>
<Form.Item
name="weight"
label="识别权重 (1-5)"
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
>
<InputNumber min={1} max={5} precision={1} step={0.1} className="hotwords-weight-input" />
</Form.Item>
</Col>
<Col xs={24} sm={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="备注">
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={255} showCount placeholder="记录热词来源或适用场景" />
</Form.Item>
</Form>
</FormDrawer>
<Modal
title={editingGroupId ? "编辑热词组" : "新增热词组"}
open={groupEditorVisible}
onCancel={() => setGroupEditorVisible(false)}
onOk={() => void handleGroupSubmit()}
confirmLoading={groupSubmitLoading}
destroyOnHidden
>
<Form form={groupForm} layout="vertical" className="hotwords-modal-form">
<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>
</PageContainer>
);
};
export default HotWords;