feat: 修改页面
parent
364e49b3df
commit
eaed89c9ec
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,174 +1,241 @@
|
|||
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 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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminPlatformConfig();
|
||||
form.setFieldsValue(data);
|
||||
} catch (e) {
|
||||
// Handled by interceptor
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
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">
|
||||
<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={(
|
||||
title={t("platformSettings.title")}
|
||||
subtitle={t("platformSettings.subtitle")}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={() => form.submit()}
|
||||
>
|
||||
{t('common.save')}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "0 4px 8px 0",
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
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}>
|
||||
<Card
|
||||
title={<><GlobalOutlined className="mr-2" /> 基础信息</>}
|
||||
className="shadow-sm mb-6"
|
||||
loading={loading}
|
||||
style={cardStyle}
|
||||
title={
|
||||
<>
|
||||
<GlobalOutlined style={{ marginRight: 8 }} />
|
||||
基础信息
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('platformSettings.projectName')}
|
||||
label={t("platformSettings.projectName")}
|
||||
name="projectName"
|
||||
rules={[{ required: true, message: t('platformSettings.projectName') }]}
|
||||
rules={[{ required: true, message: `请输入${t("platformSettings.projectName")}` }]}
|
||||
>
|
||||
<Input placeholder="例如:iMeeting 智能会议系统" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('platformSettings.desc')} name="systemDescription">
|
||||
<Input.TextArea rows={3} placeholder="系统的简要介绍..." />
|
||||
<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"
|
||||
loading={loading}
|
||||
style={cardStyle}
|
||||
title={
|
||||
<>
|
||||
<PictureOutlined style={{ marginRight: 8 }} />
|
||||
视觉资源
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t('platformSettings.logo')} name="logoUrl">
|
||||
<Input placeholder="Logo URL" className="mb-2" />
|
||||
<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>
|
||||
<ImagePreview url={Form.useWatch('logoUrl', form)} label="Logo" />
|
||||
{renderImagePreview(logoUrl, "Logo")}
|
||||
<Upload
|
||||
accept="image/*"
|
||||
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>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t('platformSettings.icon')} name="iconUrl">
|
||||
<Input placeholder="Icon URL" className="mb-2" />
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label={t("platformSettings.icon")} name="iconUrl">
|
||||
<Input placeholder="Icon URL" style={{ marginBottom: 8 }} />
|
||||
</Form.Item>
|
||||
<ImagePreview url={Form.useWatch('iconUrl', form)} label="Icon" />
|
||||
{renderImagePreview(iconUrl, "Icon")}
|
||||
<Upload
|
||||
accept="image/*"
|
||||
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>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t('platformSettings.loginBg')} name="loginBgUrl">
|
||||
<Input placeholder="Background URL" className="mb-2" />
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label={t("platformSettings.loginBg")} name="loginBgUrl">
|
||||
<Input placeholder="Background URL" style={{ marginBottom: 8 }} />
|
||||
</Form.Item>
|
||||
<ImagePreview url={Form.useWatch('loginBgUrl', form)} label="Background" />
|
||||
{renderImagePreview(loginBgUrl, "Background")}
|
||||
<Upload
|
||||
accept="image/*"
|
||||
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>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -177,25 +244,80 @@ export default function PlatformSettings() {
|
|||
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={<><FileTextOutlined className="mr-2" /> 合规与版权</>}
|
||||
className="shadow-sm"
|
||||
loading={loading}
|
||||
style={cardStyle}
|
||||
title={
|
||||
<>
|
||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||
合规与版权
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t('platformSettings.icp')} name="icpInfo">
|
||||
<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 span={12}>
|
||||
<Form.Item label={t('platformSettings.copyright')} name="copyrightInfo">
|
||||
<Input placeholder="例如:© 2026 iMeeting Team." />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,84 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Table, Card, Button, Input, Space, Modal, Form, Select,
|
||||
InputNumber, Tag, message, Popconfirm, Divider, Tooltip,
|
||||
Radio, Row, Col, Typography, Badge
|
||||
} from 'antd';
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined,
|
||||
UserOutlined, GlobalOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDict } from '../../hooks/useDict';
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDict } from "../../hooks/useDict";
|
||||
import {
|
||||
deleteHotWord,
|
||||
getHotWordPage,
|
||||
getPinyinSuggestion,
|
||||
saveHotWord,
|
||||
updateHotWord,
|
||||
deleteHotWord,
|
||||
getPinyinSuggestion,
|
||||
HotWordVO,
|
||||
HotWordDTO
|
||||
} from '../../api/business/hotword';
|
||||
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);
|
||||
|
|
@ -63,189 +88,224 @@ const HotWords: React.FC = () => {
|
|||
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) {
|
||||
if (record.isPublic === 1 && !isAdmin) {
|
||||
message.error('公开热词仅限管理员修改');
|
||||
if (record?.isPublic === 1 && !isAdmin) {
|
||||
message.error("公开热词仅限管理员修改");
|
||||
return;
|
||||
}
|
||||
|
||||
if (record) {
|
||||
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) {
|
||||
const word = e.target.value?.trim();
|
||||
if (!word || form.getFieldValue("pinyin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 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>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
title="确定删除这条热词吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
|
||||
) : (
|
||||
<Text type="secondary" size="small">无权操作</Text>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<Card
|
||||
title="热词库管理"
|
||||
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);}}
|
||||
onChange={(value) => {
|
||||
setSearchType(value);
|
||||
setCurrent(1);
|
||||
}}
|
||||
>
|
||||
<Option value={1}>租户公开</Option>
|
||||
<Option value={0}>个人私有</Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="按类别筛选"
|
||||
style={{ width: 130 }}
|
||||
style={{ width: 150 }}
|
||||
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>
|
||||
<Input
|
||||
placeholder="搜索热词原文..."
|
||||
placeholder="搜索热词原文"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}}
|
||||
style={{ width: 180 }}
|
||||
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>
|
||||
}
|
||||
>
|
||||
<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: (t) => `共 ${t} 条`,
|
||||
onChange: (p, s) => { setCurrent(p); setSize(s); }
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑热词' : '新增热词'}
|
||||
title={editingId ? "编辑热词" : "新增热词"}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
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} />
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue