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

535 lines
17 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,
Modal,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
2026-03-10 09:43:33 +00:00
import {
DeleteOutlined,
EditOutlined,
FolderOpenOutlined,
2026-03-10 09:43:33 +00:00
PlusOutlined,
SearchOutlined,
} 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";
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 [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
const [groupManageVisible, setGroupManageVisible] = useState(false);
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 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, searchWord, size]);
useEffect(() => {
void loadGroupOptions();
}, []);
const fetchData = async () => {
setLoading(true);
try {
2026-03-10 09:43:33 +00:00
const res = await getHotWordPage({
current,
size,
word: searchWord,
category: searchCategory,
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 });
}
setModalVisible(true);
};
2026-03-10 09:43:33 +00:00
const handleDelete = async (id: number) => {
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
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(), groupManageVisible ? loadGroupPage() : Promise.resolve()]);
} 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 openGroupManager = async () => {
setGroupManageVisible(true);
await loadGroupPage();
};
const openGroupEditor = (record?: HotWordGroupVO) => {
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) => {
await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined);
message.success("热词组删除成功");
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 groupColumns = [
{
title: "热词组名称",
dataIndex: "groupName",
key: "groupName",
render: (value: string) => <Text strong>{value}</Text>,
},
{
title: "热词数量",
dataIndex: "hotWordCount",
key: "hotWordCount",
render: (value: number) => <Tag color={value >= 200 ? "red" : "processing"}>{value}/200</Tag>,
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
title: "备注",
dataIndex: "remark",
key: "remark",
render: (value?: string) => value || "-",
},
{
title: "操作",
key: "action",
render: (_: unknown, record: HotWordGroupVO) => (
<Space>
<Button type="link" size="small" onClick={() => openGroupEditor(record)}>
</Button>
<Popconfirm
title="确定删除这个热词组吗?"
description="删除前必须先解除模板引用并清空组内热词。"
onConfirm={() => handleDeleteGroup(record.id)}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<Button type="link" size="small" danger>
2026-03-10 09:43:33 +00:00
</Button>
</Popconfirm>
</Space>
),
2026-03-10 09:43:33 +00:00
},
];
return (
<div className="app-page">
2026-03-10 09:43:33 +00:00
<Card
className="app-page__content-card shadow-sm"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
2026-03-10 09:43:33 +00:00
title="热词管理"
extra={
<Space wrap>
2026-03-10 09:43:33 +00:00
<Select
placeholder="按分类筛选"
2026-03-10 09:43:33 +00:00
style={{ width: 150 }}
allowClear
onChange={(value) => {
setSearchCategory(value);
setCurrent(1);
}}
>
2026-03-10 09:43:33 +00:00
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
2026-03-10 09:43:33 +00:00
<Input
placeholder="搜索热词原文"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => {
setSearchWord((e.target as HTMLInputElement).value);
setCurrent(1);
}}
style={{ width: 200 }}
/>
<Button icon={<FolderOpenOutlined />} onClick={() => void openGroupManager()}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
</Space>
}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "24px 24px 0" }}>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: "max-content" }} pagination={false} />
2026-03-10 09:43:33 +00:00
</div>
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
</Card>
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="热词组管理"
open={groupManageVisible}
onCancel={() => setGroupManageVisible(false)}
footer={null}
width={900}
destroyOnHidden
>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "flex-end" }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openGroupEditor()}>
</Button>
</div>
<Table rowKey="id" columns={groupColumns} dataSource={groupData} loading={groupLoading} pagination={false} />
</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;