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.MeetingDTO;
import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.security.LoginUser; import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.PromptTemplateService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.fontbox.ttf.TrueTypeCollection; import org.apache.fontbox.ttf.TrueTypeCollection;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPage;
@ -42,6 +45,9 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; 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.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -55,15 +61,18 @@ import java.util.regex.Pattern;
public class MeetingController { public class MeetingController {
private final MeetingService meetingService; private final MeetingService meetingService;
private final AiTaskService aiTaskService;
private final PromptTemplateService promptTemplateService; private final PromptTemplateService promptTemplateService;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final String uploadPath; private final String uploadPath;
public MeetingController(MeetingService meetingService, public MeetingController(MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService, PromptTemplateService promptTemplateService,
StringRedisTemplate redisTemplate, StringRedisTemplate redisTemplate,
@Value("${app.upload-path}") String uploadPath) { @Value("${app.upload-path}") String uploadPath) {
this.meetingService = meetingService; this.meetingService = meetingService;
this.aiTaskService = aiTaskService;
this.promptTemplateService = promptTemplateService; this.promptTemplateService = promptTemplateService;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.uploadPath = uploadPath; this.uploadPath = uploadPath;
@ -161,14 +170,31 @@ public class MeetingController {
@GetMapping("/{id}/summary/export") @GetMapping("/{id}/summary/export")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) { 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); MeetingVO meeting = meetingService.getDetail(id);
if (meeting == null) { if (meeting == null) {
throw new RuntimeException("数据未找到,请刷新后重试"); 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总结为空"); 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()) String safeTitle = (meeting.getTitle() == null || meeting.getTitle().trim().isEmpty())
? "meeting-summary-" + id ? "meeting-summary-" + id
: meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_"); : meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
@ -177,18 +203,32 @@ public class MeetingController {
byte[] bytes; byte[] bytes;
String ext; String ext;
String contentType; String contentType;
Path exportDir = Paths.get(basePath, "meetings", String.valueOf(id), "exports");
Files.createDirectories(exportDir);
if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) { if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) {
bytes = buildWordBytes(meeting);
ext = "docx"; ext = "docx";
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if ("pdf".equalsIgnoreCase(format)) { } else if ("pdf".equalsIgnoreCase(format)) {
bytes = buildPdfBytes(meeting);
ext = "pdf"; ext = "pdf";
contentType = MediaType.APPLICATION_PDF_VALUE; contentType = MediaType.APPLICATION_PDF_VALUE;
} else { } else {
throw new RuntimeException("格式化失败"); 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 filename = safeTitle + "-AI-总结." + ext;
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok() 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}") @GetMapping("/transcripts/{id}")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) { public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {

View File

@ -355,9 +355,26 @@ export default function AppLayout() {
display: 'flex', display: 'flex',
flexDirection: 'column' 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 /> <Outlet />
</div> </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> </Content>
</Layout> </Layout>
</Layout> </Layout>

View File

@ -1,174 +1,241 @@
import { import {
Button, Button,
Card, Card,
Col,
Divider,
Form, Form,
Input, Input,
message, Row,
Space,
Typography, Typography,
Upload, Upload,
Row, message,
Col,
Divider
} from "antd"; } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "../api";
import { import {
UploadOutlined, FileTextOutlined,
SaveOutlined,
GlobalOutlined, GlobalOutlined,
PictureOutlined, PictureOutlined,
FileTextOutlined SaveOutlined,
UploadOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "../api";
import type { SysPlatformConfig } from "../types"; import type { SysPlatformConfig } from "../types";
import PageHeader from "../components/shared/PageHeader"; 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() { export default function PlatformSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm<SysPlatformConfig>();
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(() => {
const loadConfig = async () => { const loadConfig = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await getAdminPlatformConfig(); const data = await getAdminPlatformConfig();
form.setFieldsValue(data); form.setFieldsValue(data);
} catch (e) {
// Handled by interceptor
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { void loadConfig();
loadConfig(); }, [form]);
}, []);
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => { const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
try { try {
const url = await uploadPlatformAsset(file); const url = await uploadPlatformAsset(file);
form.setFieldValue(fieldName, url); form.setFieldValue(fieldName, url);
message.success(t('common.success')); message.success(t("common.success"));
} catch (e) { } catch {
// Handled by interceptor // handled by interceptor
} }
return false; // 阻止自动上传 return false;
}; };
const onFinish = async (values: SysPlatformConfig) => { const onFinish = async (values: SysPlatformConfig) => {
setSaving(true); setSaving(true);
try { try {
await updatePlatformConfig(values); await updatePlatformConfig(values);
message.success(t('common.success')); sessionStorage.setItem("platformConfig", JSON.stringify(values));
} catch (e) { message.success(t("common.success"));
// Handled by interceptor
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const ImagePreview = ({ url, label }: { url?: string; label: string }) => ( const footerPreview = useMemo(() => {
<div className="flex flex-col items-center justify-center p-4 border border-dashed border-gray-300 rounded-lg bg-gray-50 h-32"> 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 ? ( {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 }} /> <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>
)} )}
</div> </div>
); );
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div
style={{
height: "100%",
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div style={{ flexShrink: 0 }}>
<PageHeader <PageHeader
title={t('platformSettings.title')} title={t("platformSettings.title")}
subtitle={t('platformSettings.subtitle')} subtitle={t("platformSettings.subtitle")}
extra={( extra={
<Button <Button
type="primary" type="primary"
icon={<SaveOutlined />} icon={<SaveOutlined />}
loading={saving} loading={saving}
onClick={() => form.submit()} onClick={() => form.submit()}
> >
{t('common.save')} {t("common.save")}
</Button> </Button>
)} }
/> />
</div>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: "0 4px 8px 0",
}}
>
<Form <Form
form={form} form={form}
layout="vertical" layout="vertical"
onFinish={onFinish} onFinish={onFinish}
initialValues={{ projectName: 'iMeeting' }} initialValues={{ projectName: "iMeeting" }}
style={{ maxWidth: 1280, margin: "0 auto", paddingBottom: 8 }}
> >
<Row gutter={24}> <Row gutter={[24, 24]}>
<Col span={24}> <Col span={24}>
<Card <Card
title={<><GlobalOutlined className="mr-2" /> </>} loading={loading}
className="shadow-sm mb-6" style={cardStyle}
title={
<>
<GlobalOutlined style={{ marginRight: 8 }} />
</>
}
> >
<Form.Item <Form.Item
label={t('platformSettings.projectName')} label={t("platformSettings.projectName")}
name="projectName" name="projectName"
rules={[{ required: true, message: t('platformSettings.projectName') }]} rules={[{ required: true, message: `请输入${t("platformSettings.projectName")}` }]}
> >
<Input placeholder="例如iMeeting 智能会议系统" /> <Input placeholder="例如iMeeting 智能会议系统" />
</Form.Item> </Form.Item>
<Form.Item label={t('platformSettings.desc')} name="systemDescription"> <Form.Item label={t("platformSettings.desc")} name="systemDescription" style={{ marginBottom: 0 }}>
<Input.TextArea rows={3} placeholder="系统的简要介绍..." /> <Input.TextArea rows={3} placeholder="填写系统简介、定位或品牌说明" />
</Form.Item> </Form.Item>
</Card> </Card>
</Col> </Col>
<Col span={24}> <Col span={24}>
<Card <Card
title={<><PictureOutlined className="mr-2" /> </>} loading={loading}
className="shadow-sm mb-6" style={cardStyle}
title={
<>
<PictureOutlined style={{ marginRight: 8 }} />
</>
}
> >
<Row gutter={24}> <Row gutter={[24, 24]}>
<Col span={8}> <Col xs={24} md={8}>
<Form.Item label={t('platformSettings.logo')} name="logoUrl"> <Form.Item label={t("platformSettings.logo")} name="logoUrl">
<Input placeholder="Logo URL" className="mb-2" /> <Input placeholder="Logo URL" style={{ marginBottom: 8 }} />
</Form.Item> </Form.Item>
<ImagePreview url={Form.useWatch('logoUrl', form)} label="Logo" /> {renderImagePreview(logoUrl, "Logo")}
<Upload <Upload
accept="image/*" accept="image/*"
showUploadList={false} showUploadList={false}
beforeUpload={(file) => handleUpload(file, 'logoUrl')} beforeUpload={(file) => handleUpload(file, "logoUrl")}
> >
<Button icon={<UploadOutlined />} block className="mt-2"> Logo</Button> <Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
Logo
</Button>
</Upload> </Upload>
</Col> </Col>
<Col span={8}>
<Form.Item label={t('platformSettings.icon')} name="iconUrl"> <Col xs={24} md={8}>
<Input placeholder="Icon URL" className="mb-2" /> <Form.Item label={t("platformSettings.icon")} name="iconUrl">
<Input placeholder="Icon URL" style={{ marginBottom: 8 }} />
</Form.Item> </Form.Item>
<ImagePreview url={Form.useWatch('iconUrl', form)} label="Icon" /> {renderImagePreview(iconUrl, "Icon")}
<Upload <Upload
accept="image/*" accept="image/*"
showUploadList={false} showUploadList={false}
beforeUpload={(file) => handleUpload(file, 'iconUrl')} beforeUpload={(file) => handleUpload(file, "iconUrl")}
> >
<Button icon={<UploadOutlined />} block className="mt-2"> Icon</Button> <Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
Icon
</Button>
</Upload> </Upload>
</Col> </Col>
<Col span={8}>
<Form.Item label={t('platformSettings.loginBg')} name="loginBgUrl"> <Col xs={24} md={8}>
<Input placeholder="Background URL" className="mb-2" /> <Form.Item label={t("platformSettings.loginBg")} name="loginBgUrl">
<Input placeholder="Background URL" style={{ marginBottom: 8 }} />
</Form.Item> </Form.Item>
<ImagePreview url={Form.useWatch('loginBgUrl', form)} label="Background" /> {renderImagePreview(loginBgUrl, "Background")}
<Upload <Upload
accept="image/*" accept="image/*"
showUploadList={false} showUploadList={false}
beforeUpload={(file) => handleUpload(file, 'loginBgUrl')} beforeUpload={(file) => handleUpload(file, "loginBgUrl")}
> >
<Button icon={<UploadOutlined />} block className="mt-2"></Button> <Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
</Button>
</Upload> </Upload>
</Col> </Col>
</Row> </Row>
@ -177,25 +244,80 @@ export default function PlatformSettings() {
<Col span={24}> <Col span={24}>
<Card <Card
title={<><FileTextOutlined className="mr-2" /> </>} loading={loading}
className="shadow-sm" style={cardStyle}
title={
<>
<FileTextOutlined style={{ marginRight: 8 }} />
</>
}
> >
<Row gutter={16}> <Row gutter={[16, 16]}>
<Col span={12}> <Col xs={24} md={12}>
<Form.Item label={t('platformSettings.icp')} name="icpInfo"> <Form.Item label={t("platformSettings.icp")} name="icpInfo">
<Input placeholder="例如京ICP备12345678号" /> <Input placeholder="例如:京 ICP 12345678 号" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col xs={24} md={12}>
<Form.Item label={t('platformSettings.copyright')} name="copyrightInfo"> <Form.Item label={t("platformSettings.copyright")} name="copyrightInfo">
<Input placeholder="例如:© 2026 iMeeting Team." /> <Input placeholder="例如:© 2026 iMeeting Team. All rights reserved." />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </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> </Card>
</Col> </Col>
</Row> </Row>
</Form> </Form>
</div> </div>
</div>
); );
} }

View File

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