feat: 修改页面

dev_na
chenhao 2026-03-10 17:43:33 +08:00
parent 364e49b3df
commit eaed89c9ec
4 changed files with 628 additions and 312 deletions

View File

@ -6,10 +6,13 @@ import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.fontbox.ttf.TrueTypeCollection;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@ -42,6 +45,9 @@ import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@ -55,15 +61,18 @@ import java.util.regex.Pattern;
public class MeetingController {
private final MeetingService meetingService;
private final AiTaskService aiTaskService;
private final PromptTemplateService promptTemplateService;
private final StringRedisTemplate redisTemplate;
private final String uploadPath;
public MeetingController(MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
StringRedisTemplate redisTemplate,
@Value("${app.upload-path}") String uploadPath) {
this.meetingService = meetingService;
this.aiTaskService = aiTaskService;
this.promptTemplateService = promptTemplateService;
this.redisTemplate = redisTemplate;
this.uploadPath = uploadPath;
@ -161,14 +170,31 @@ public class MeetingController {
@GetMapping("/{id}/summary/export")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Meeting meetingEntity = meetingService.getById(id);
if (meetingEntity == null) {
throw new RuntimeException("数据未找到,请刷新后重试");
}
if (!canAccessMeeting(meetingEntity, loginUser)) {
throw new RuntimeException("无权下载此会议总结");
}
MeetingVO meeting = meetingService.getDetail(id);
if (meeting == null) {
throw new RuntimeException("数据未找到,请刷新后重试");
}
if (meeting.getSummaryContent() == null || meeting.getSummaryContent().trim().isEmpty()) {
AiTask latestSummaryTask = findLatestSummaryTask(meetingEntity);
if (latestSummaryTask == null || latestSummaryTask.getResultFilePath() == null || latestSummaryTask.getResultFilePath().isBlank()) {
throw new RuntimeException(" AI总结为空");
}
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path summarySourcePath = Paths.get(basePath, latestSummaryTask.getResultFilePath().replace("\\", "/"));
if (!Files.exists(summarySourcePath)) {
throw new RuntimeException("总结源文件不存在,请重新总结后再试");
}
String safeTitle = (meeting.getTitle() == null || meeting.getTitle().trim().isEmpty())
? "meeting-summary-" + id
: meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
@ -177,18 +203,32 @@ public class MeetingController {
byte[] bytes;
String ext;
String contentType;
Path exportDir = Paths.get(basePath, "meetings", String.valueOf(id), "exports");
Files.createDirectories(exportDir);
if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) {
bytes = buildWordBytes(meeting);
ext = "docx";
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if ("pdf".equalsIgnoreCase(format)) {
bytes = buildPdfBytes(meeting);
ext = "pdf";
contentType = MediaType.APPLICATION_PDF_VALUE;
} else {
throw new RuntimeException("格式化失败");
}
Path exportPath = exportDir.resolve(latestSummaryTask.getId() + "." + ext);
boolean needRegenerate = !Files.exists(exportPath) ||
Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis();
if (needRegenerate) {
String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8);
meeting.setSummaryContent(stripFrontMatter(markdown));
bytes = "docx".equals(ext) ? buildWordBytes(meeting) : buildPdfBytes(meeting);
Files.write(exportPath, bytes);
} else {
bytes = Files.readAllBytes(exportPath);
}
String filename = safeTitle + "-AI-总结." + ext;
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
@ -200,6 +240,56 @@ public class MeetingController {
}
}
private AiTask findLatestSummaryTask(Meeting meeting) {
if (meeting.getLatestSummaryTaskId() != null) {
AiTask task = aiTaskService.getById(meeting.getLatestSummaryTaskId());
if (task != null && "SUMMARY".equals(task.getTaskType()) && Integer.valueOf(2).equals(task.getStatus())
&& task.getResultFilePath() != null && !task.getResultFilePath().isBlank()) {
return task;
}
}
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.eq(AiTask::getStatus, 2)
.isNotNull(AiTask::getResultFilePath)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private boolean canAccessMeeting(Meeting meeting, LoginUser user) {
if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin())) {
return true;
}
if (meeting.getCreatorId() != null && meeting.getCreatorId().equals(user.getUserId())) {
return true;
}
if (meeting.getParticipants() == null || meeting.getParticipants().isBlank()) {
return false;
}
String target = "," + user.getUserId() + ",";
return ("," + meeting.getParticipants() + ",").contains(target);
}
private String stripFrontMatter(String markdown) {
if (markdown == null || markdown.isBlank()) {
return markdown;
}
if (!markdown.startsWith("---")) {
return markdown;
}
int second = markdown.indexOf("\n---", 3);
if (second < 0) {
return markdown;
}
int contentStart = second + 4;
if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') {
contentStart++;
}
return markdown.substring(contentStart).trim();
}
@GetMapping("/transcripts/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {

View File

@ -355,9 +355,26 @@ export default function AppLayout() {
display: 'flex',
flexDirection: 'column'
}}>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Outlet />
</div>
{(platformConfig?.icpInfo || platformConfig?.copyrightInfo) && (
<div
style={{
flexShrink: 0,
marginTop: 16,
paddingTop: 12,
borderTop: '1px solid #f0f0f0',
color: '#8c8c8c',
fontSize: 12,
textAlign: 'center',
lineHeight: 1.8,
}}
>
{platformConfig?.icpInfo && <span style={{ margin: '0 12px', whiteSpace: 'nowrap' }}>{platformConfig.icpInfo}</span>}
{platformConfig?.copyrightInfo && <span style={{ margin: '0 12px', whiteSpace: 'nowrap' }}>{platformConfig.copyrightInfo}</span>}
</div>
)}
</Content>
</Layout>
</Layout>

View File

@ -1,201 +1,323 @@
import {
Button,
Card,
Col,
Divider,
Form,
Input,
message,
Space,
Row,
Typography,
Upload,
Row,
Col,
Divider
message,
} from "antd";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "../api";
import {
UploadOutlined,
SaveOutlined,
FileTextOutlined,
GlobalOutlined,
PictureOutlined,
FileTextOutlined
SaveOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "../api";
import type { SysPlatformConfig } from "../types";
import PageHeader from "../components/shared/PageHeader";
const { Title, Text } = Typography;
const { Text } = Typography;
const cardStyle = {
boxShadow: "0 1px 2px rgba(0,0,0,0.08), 0 1px 6px -1px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.05)",
};
export default function PlatformSettings() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const [form] = Form.useForm<SysPlatformConfig>();
const loadConfig = async () => {
setLoading(true);
try {
const data = await getAdminPlatformConfig();
form.setFieldsValue(data);
} catch (e) {
// Handled by interceptor
} finally {
setLoading(false);
}
};
const logoUrl = Form.useWatch("logoUrl", form);
const iconUrl = Form.useWatch("iconUrl", form);
const loginBgUrl = Form.useWatch("loginBgUrl", form);
const projectName = Form.useWatch("projectName", form);
const icpInfo = Form.useWatch("icpInfo", form);
const copyrightInfo = Form.useWatch("copyrightInfo", form);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const data = await getAdminPlatformConfig();
form.setFieldsValue(data);
} finally {
setLoading(false);
}
};
void loadConfig();
}, [form]);
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
try {
const url = await uploadPlatformAsset(file);
form.setFieldValue(fieldName, url);
message.success(t('common.success'));
} catch (e) {
// Handled by interceptor
message.success(t("common.success"));
} catch {
// handled by interceptor
}
return false; // 阻止自动上传
return false;
};
const onFinish = async (values: SysPlatformConfig) => {
setSaving(true);
try {
await updatePlatformConfig(values);
message.success(t('common.success'));
} catch (e) {
// Handled by interceptor
sessionStorage.setItem("platformConfig", JSON.stringify(values));
message.success(t("common.success"));
} finally {
setSaving(false);
}
};
const ImagePreview = ({ url, label }: { url?: string; label: string }) => (
<div className="flex flex-col items-center justify-center p-4 border border-dashed border-gray-300 rounded-lg bg-gray-50 h-32">
const footerPreview = useMemo(() => {
return [icpInfo, copyrightInfo].filter(Boolean).join(" | ");
}, [copyrightInfo, icpInfo]);
const renderImagePreview = (url?: string, label?: string) => (
<div
style={{
height: 96,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: "1px dashed #d9d9d9",
borderRadius: 8,
background: "#fafafa",
padding: 12,
}}
>
{url ? (
<img src={url} alt={label} className="max-h-full max-w-full object-contain" />
<img
src={url}
alt={label}
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
/>
) : (
<div className="text-gray-400">
<div style={{ textAlign: "center", color: "#bfbfbf" }}>
<PictureOutlined style={{ fontSize: 24 }} />
<div className="text-xs mt-1">{t('platformSettings.uploadHint')}</div>
<div style={{ marginTop: 4, fontSize: 12 }}>{t("platformSettings.uploadHint")}</div>
</div>
)}
</div>
);
return (
<div className="p-6 max-w-4xl mx-auto">
<PageHeader
title={t('platformSettings.title')}
subtitle={t('platformSettings.subtitle')}
extra={(
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={() => form.submit()}
>
{t('common.save')}
</Button>
)}
/>
<div
style={{
height: "100%",
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div style={{ flexShrink: 0 }}>
<PageHeader
title={t("platformSettings.title")}
subtitle={t("platformSettings.subtitle")}
extra={
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={() => form.submit()}
>
{t("common.save")}
</Button>
}
/>
</div>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{ projectName: 'iMeeting' }}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: "0 4px 8px 0",
}}
>
<Row gutter={24}>
<Col span={24}>
<Card
title={<><GlobalOutlined className="mr-2" /> </>}
className="shadow-sm mb-6"
>
<Form.Item
label={t('platformSettings.projectName')}
name="projectName"
rules={[{ required: true, message: t('platformSettings.projectName') }]}
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{ projectName: "iMeeting" }}
style={{ maxWidth: 1280, margin: "0 auto", paddingBottom: 8 }}
>
<Row gutter={[24, 24]}>
<Col span={24}>
<Card
loading={loading}
style={cardStyle}
title={
<>
<GlobalOutlined style={{ marginRight: 8 }} />
</>
}
>
<Input placeholder="例如iMeeting 智能会议系统" />
</Form.Item>
<Form.Item label={t('platformSettings.desc')} name="systemDescription">
<Input.TextArea rows={3} placeholder="系统的简要介绍..." />
</Form.Item>
</Card>
</Col>
<Form.Item
label={t("platformSettings.projectName")}
name="projectName"
rules={[{ required: true, message: `请输入${t("platformSettings.projectName")}` }]}
>
<Input placeholder="例如iMeeting 智能会议系统" />
</Form.Item>
<Form.Item label={t("platformSettings.desc")} name="systemDescription" style={{ marginBottom: 0 }}>
<Input.TextArea rows={3} placeholder="填写系统简介、定位或品牌说明" />
</Form.Item>
</Card>
</Col>
<Col span={24}>
<Card
title={<><PictureOutlined className="mr-2" /> </>}
className="shadow-sm mb-6"
>
<Row gutter={24}>
<Col span={8}>
<Form.Item label={t('platformSettings.logo')} name="logoUrl">
<Input placeholder="Logo URL" className="mb-2" />
</Form.Item>
<ImagePreview url={Form.useWatch('logoUrl', form)} label="Logo" />
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, 'logoUrl')}
>
<Button icon={<UploadOutlined />} block className="mt-2"> Logo</Button>
</Upload>
</Col>
<Col span={8}>
<Form.Item label={t('platformSettings.icon')} name="iconUrl">
<Input placeholder="Icon URL" className="mb-2" />
</Form.Item>
<ImagePreview url={Form.useWatch('iconUrl', form)} label="Icon" />
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, 'iconUrl')}
>
<Button icon={<UploadOutlined />} block className="mt-2"> Icon</Button>
</Upload>
</Col>
<Col span={8}>
<Form.Item label={t('platformSettings.loginBg')} name="loginBgUrl">
<Input placeholder="Background URL" className="mb-2" />
</Form.Item>
<ImagePreview url={Form.useWatch('loginBgUrl', form)} label="Background" />
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, 'loginBgUrl')}
>
<Button icon={<UploadOutlined />} block className="mt-2"></Button>
</Upload>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
loading={loading}
style={cardStyle}
title={
<>
<PictureOutlined style={{ marginRight: 8 }} />
</>
}
>
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Form.Item label={t("platformSettings.logo")} name="logoUrl">
<Input placeholder="Logo URL" style={{ marginBottom: 8 }} />
</Form.Item>
{renderImagePreview(logoUrl, "Logo")}
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, "logoUrl")}
>
<Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
Logo
</Button>
</Upload>
</Col>
<Col span={24}>
<Card
title={<><FileTextOutlined className="mr-2" /> </>}
className="shadow-sm"
>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('platformSettings.icp')} name="icpInfo">
<Input placeholder="例如京ICP备12345678号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('platformSettings.copyright')} name="copyrightInfo">
<Input placeholder="例如:© 2026 iMeeting Team." />
</Form.Item>
</Col>
</Row>
</Card>
</Col>
</Row>
</Form>
<Col xs={24} md={8}>
<Form.Item label={t("platformSettings.icon")} name="iconUrl">
<Input placeholder="Icon URL" style={{ marginBottom: 8 }} />
</Form.Item>
{renderImagePreview(iconUrl, "Icon")}
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, "iconUrl")}
>
<Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
Icon
</Button>
</Upload>
</Col>
<Col xs={24} md={8}>
<Form.Item label={t("platformSettings.loginBg")} name="loginBgUrl">
<Input placeholder="Background URL" style={{ marginBottom: 8 }} />
</Form.Item>
{renderImagePreview(loginBgUrl, "Background")}
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, "loginBgUrl")}
>
<Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
</Button>
</Upload>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
loading={loading}
style={cardStyle}
title={
<>
<FileTextOutlined style={{ marginRight: 8 }} />
</>
}
>
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.icp")} name="icpInfo">
<Input placeholder="例如:京 ICP 备 12345678 号" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("platformSettings.copyright")} name="copyrightInfo">
<Input placeholder="例如:© 2026 iMeeting Team. All rights reserved." />
</Form.Item>
</Col>
</Row>
<Divider orientation="left" orientationMargin={0}>
</Divider>
<div
style={{
border: "1px solid #e2e8f0",
borderRadius: 12,
background: "#f8fafc",
padding: 16,
}}
>
<div style={{ fontSize: 14, fontWeight: 600, color: "#1e293b" }}>
{projectName || "iMeeting"}
</div>
<Text style={{ display: "block", marginTop: 8, fontSize: 12, color: "#64748b" }}>
</Text>
<Divider style={{ margin: "12px 0" }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "8px 24px",
fontSize: 12,
color: "#64748b",
}}
>
<span>{icpInfo || "未填写 ICP 备案号"}</span>
<span>{copyrightInfo || "未填写版权信息"}</span>
</div>
{footerPreview ? (
<div
style={{
marginTop: 12,
borderRadius: 8,
background: "#fff",
padding: "8px 12px",
textAlign: "center",
fontSize: 12,
color: "#64748b",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
>
{footerPreview}
</div>
) : null}
</div>
</Card>
</Col>
</Row>
</Form>
</div>
</div>
);
}

View File

@ -1,251 +1,311 @@
import React, { useState, useEffect } from 'react';
import {
Table, Card, Button, Input, Space, Modal, Form, Select,
InputNumber, Tag, message, Popconfirm, Divider, Tooltip,
Radio, Row, Col, Typography, Badge
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined,
UserOutlined, GlobalOutlined
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useDict } from '../../hooks/useDict';
import {
getHotWordPage,
saveHotWord,
updateHotWord,
deleteHotWord,
import React, { useEffect, useMemo, useState } from "react";
import {
Badge,
Button,
Card,
Col,
Form,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Radio,
Row,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
} from "antd";
import {
DeleteOutlined,
EditOutlined,
GlobalOutlined,
PlusOutlined,
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
import {
deleteHotWord,
getHotWordPage,
getPinyinSuggestion,
HotWordVO,
HotWordDTO
} from '../../api/business/hotword';
saveHotWord,
updateHotWord,
type HotWordVO,
} from "../../api/business/hotword";
const { Option } = Select;
const { Text } = Typography;
type HotWordFormValues = {
word: string;
pinyin?: string;
category?: string;
weight: number;
status: number;
isPublic: number;
remark?: string;
};
const HotWords: React.FC = () => {
const { t } = useTranslation();
const [form] = Form.useForm();
const { items: categories, loading: dictLoading } = useDict('biz_hotword_category');
const [form] = Form.useForm<HotWordFormValues>();
const { items: categories } = useDict("biz_hotword_category");
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 [searchWord, setSearchWord] = useState("");
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [searchType, setSearchType] = useState<number | undefined>(undefined);
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
// 获取当前用户信息
const userProfile = React.useMemo(() => {
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
// 判定是否具有管理员权限 (平台管理员或租户管理员)
const isAdmin = React.useMemo(() => {
const isAdmin = useMemo(() => {
return userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
}, [userProfile]);
useEffect(() => {
fetchData();
}, [current, size, searchWord, searchCategory, searchType]);
void fetchData();
}, [current, searchCategory, searchType, searchWord, size]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getHotWordPage({
current,
size,
word: searchWord,
const res = await getHotWordPage({
current,
size,
word: searchWord,
category: searchCategory,
isPublic: searchType
isPublic: searchType,
});
if (res.data && res.data.data) {
if (res.data?.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleOpenModal = (record?: HotWordVO) => {
if (record?.isPublic === 1 && !isAdmin) {
message.error("公开热词仅限管理员修改");
return;
}
if (record) {
if (record.isPublic === 1 && !isAdmin) {
message.error('公开热词仅限管理员修改');
return;
}
setEditingId(record.id);
form.setFieldsValue({
...record
word: record.word,
pinyin: record.pinyinList?.[0] || "",
category: record.category,
weight: record.weight,
status: record.status,
isPublic: record.isPublic,
remark: record.remark,
});
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ weight: 2, status: 1, isPublic: 0 });
}
setModalVisible(true);
};
const handleDelete = async (id: number) => {
try {
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
} catch {
// handled by interceptor
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
const payload: any = {
const payload = {
...values,
pinyinList: values.pinyinList
? (Array.isArray(values.pinyinList) ? values.pinyinList : values.pinyinList.split(',').map((s: string) => s.trim()).filter(Boolean))
: []
matchStrategy: 1,
pinyinList: values.pinyin ? [values.pinyin.trim()] : [],
};
if (editingId) {
await updateHotWord({ ...payload, id: editingId });
message.success('更新成功');
message.success("更新成功");
} else {
await saveHotWord(payload);
message.success('添加成功');
message.success("新增成功");
}
setModalVisible(false);
fetchData();
} catch (err) {
console.error(err);
await fetchData();
} finally {
setSubmitLoading(false);
}
};
const handleWordBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
const word = e.target.value;
if (word) {
try {
const res = await getPinyinSuggestion(word);
if (res.data && res.data.data) {
form.setFieldsValue({ pinyinList: res.data.data.join(', ') });
}
} catch (err) {
console.error(err);
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 columns = [
{
title: '热词原文',
dataIndex: 'word',
key: 'word',
title: "热词原文",
dataIndex: "word",
key: "word",
render: (text: string, record: HotWordVO) => (
<Space>
<Text strong>{text}</Text>
{record.isPublic === 1 ? (
<Tooltip title="租户公开"><GlobalOutlined style={{ color: '#52c41a' }} /></Tooltip>
<Tooltip title="租户公开">
<GlobalOutlined style={{ color: "#52c41a" }} />
</Tooltip>
) : (
<Tooltip title="个人私有"><UserOutlined style={{ color: '#1890ff' }} /></Tooltip>
<Tooltip title="个人私有">
<UserOutlined style={{ color: "#1890ff" }} />
</Tooltip>
)}
</Space>
)
),
},
{
title: '拼音',
dataIndex: 'pinyinList',
key: 'pinyinList',
render: (list: string[]) => (
<Space size={[0, 4]} wrap>
{list?.map(p => <Tag key={p} style={{ fontSize: '11px', borderRadius: 4 }}>{p}</Tag>)}
</Space>
)
title: "拼音",
dataIndex: "pinyinList",
key: "pinyinList",
render: (list: string[]) =>
list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
},
{
title: '类别',
dataIndex: 'category',
key: 'category',
render: (val: string) => categories.find(i => i.itemValue === val)?.itemLabel || val
title: "类别",
dataIndex: "category",
key: "category",
render: (value: string) => categories.find((item) => item.itemValue === value)?.itemLabel || value || "-",
},
{
title: '范围',
dataIndex: 'isPublic',
key: 'isPublic',
render: (val: number) => val === 1 ? <Tag color="green"></Tag> : <Tag color="blue"></Tag>
title: "范围",
dataIndex: "isPublic",
key: "isPublic",
render: (value: number) => (value === 1 ? <Tag color="green"></Tag> : <Tag color="blue"></Tag>),
},
{
title: '权重',
dataIndex: 'weight',
key: 'weight',
render: (val: number) => <Tag color="orange">{val}</Tag>
title: "权重",
dataIndex: "weight",
key: "weight",
render: (value: number) => <Tag color="orange">{value}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: number) => status === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />
title: "状态",
dataIndex: "status",
key: "status",
render: (value: number) =>
value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
title: '操作',
key: 'action',
render: (_: any, record: HotWordVO) => {
title: "操作",
key: "action",
render: (_: unknown, record: HotWordVO) => {
const isMine = record.creatorId === userProfile.userId;
const canEdit = record.isPublic === 1 ? isAdmin : (isMine || isAdmin);
const canEdit = record.isPublic === 1 ? isAdmin : isMine || isAdmin;
if (!canEdit) {
return <Text type="secondary"></Text>;
}
return (
<Space size="middle">
{canEdit ? (
<>
<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>
</>
) : (
<Text type="secondary" size="small"></Text>
)}
<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 (
<div style={{ padding: '24px' }}>
<Card
title="热词库管理"
<div className="flex h-full flex-col overflow-hidden">
<Card
className="flex-1 overflow-hidden shadow-sm"
styles={{ body: { padding: 24, height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}
title="热词管理"
extra={
<Space wrap>
<Select
placeholder="热词类型"
style={{ width: 110 }}
allowClear
onChange={v => {setSearchType(v); setCurrent(1);}}
<Select
placeholder="热词类型"
style={{ width: 110 }}
allowClear
onChange={(value) => {
setSearchType(value);
setCurrent(1);
}}
>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
<Select
placeholder="按类别筛选"
style={{ width: 130 }}
allowClear
onChange={v => {setSearchCategory(v); setCurrent(1);}}
<Select
placeholder="按类别筛选"
style={{ width: 150 }}
allowClear
onChange={(value) => {
setSearchCategory(value);
setCurrent(1);
}}
>
{categories.map(c => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
<Input
placeholder="搜索热词原文..."
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}}
style={{ width: 180 }}
<Input
placeholder="搜索热词原文"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => {
setSearchWord((e.target as HTMLInputElement).value);
setCurrent(1);
}}
style={{ width: 200 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
@ -253,45 +313,72 @@ const HotWords: React.FC = () => {
</Space>
}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current,
pageSize: size,
total,
showTotal: (t) => `${t}`,
onChange: (p, s) => { setCurrent(p); setSize(s); }
}}
/>
<div className="min-h-0 flex-1">
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ y: "calc(100vh - 340px)" }}
pagination={{
current,
pageSize: size,
total,
showTotal: (value) => `${value}`,
onChange: (page, pageSize) => {
setCurrent(page);
setSize(pageSize);
},
}}
/>
</div>
</Card>
<Modal
title={editingId ? '编辑热词' : '新增热词'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={submitLoading}
width={550}
<Modal
title={editingId ? "编辑热词" : "新增热词"}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={submitLoading}
width={560}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: '16px' }}>
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: '请输入热词原文' }]}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="word"
label="热词原文"
rules={[{ required: true, message: "请输入热词原文" }]}
>
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
</Form.Item>
<Form.Item
name="pinyin"
label="拼音"
tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"
>
<Input placeholder="例如hui yi" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="热词分类">
<Select placeholder="请选择分类" allowClear>
{categories.map(item => <Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>)}
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="weight" label="识别权重 (1-5)" tooltip="权重越高,识别引擎越倾向于将其识别为该词">
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: '100%' }} />
<Form.Item
name="weight"
label="识别权重 (1-5)"
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
>
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
@ -305,20 +392,20 @@ const HotWords: React.FC = () => {
</Select>
</Form.Item>
</Col>
{isAdmin && (
{isAdmin ? (
<Col span={12}>
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内所有成员均可共享此热词">
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内成员都可以共享这条热词">
<Radio.Group>
<Radio value={1}></Radio>
<Radio value={0}></Radio>
</Radio.Group>
</Form.Item>
</Col>
)}
) : null}
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="记录热词的来源或用途" />
<Input.TextArea rows={2} placeholder="记录热词来源或适用场景" />
</Form.Item>
</Form>
</Modal>