From eaed89c9ec0bde2ad883e959649381f7ce129911 Mon Sep 17 00:00:00 2001 From: chenhao Date: Tue, 10 Mar 2026 17:43:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/biz/MeetingController.java | 96 +++- frontend/src/layouts/AppLayout.tsx | 19 +- frontend/src/pages/PlatformSettings.tsx | 408 +++++++++++------ frontend/src/pages/business/HotWords.tsx | 417 +++++++++++------- 4 files changed, 628 insertions(+), 312 deletions(-) diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 8120de8..16f8c37 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -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 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() + .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> getTranscripts(@PathVariable Long id) { diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 08bffa7..9423779 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -355,9 +355,26 @@ export default function AppLayout() { display: 'flex', flexDirection: 'column' }}> -
+
+ {(platformConfig?.icpInfo || platformConfig?.copyrightInfo) && ( +
+ {platformConfig?.icpInfo && {platformConfig.icpInfo}} + {platformConfig?.copyrightInfo && {platformConfig.copyrightInfo}} +
+ )} diff --git a/frontend/src/pages/PlatformSettings.tsx b/frontend/src/pages/PlatformSettings.tsx index 3475fad..f6034fc 100644 --- a/frontend/src/pages/PlatformSettings.tsx +++ b/frontend/src/pages/PlatformSettings.tsx @@ -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(); - 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 }) => ( -
+ const footerPreview = useMemo(() => { + return [icpInfo, copyrightInfo].filter(Boolean).join(" | "); + }, [copyrightInfo, icpInfo]); + + const renderImagePreview = (url?: string, label?: string) => ( +
{url ? ( - {label} + {label} ) : ( -
+
-
{t('platformSettings.uploadHint')}
+
{t("platformSettings.uploadHint")}
)}
); return ( -
- } - loading={saving} - onClick={() => form.submit()} - > - {t('common.save')} - - )} - /> +
+
+ } + loading={saving} + onClick={() => form.submit()} + > + {t("common.save")} + + } + /> +
-
- - - 基础信息} - className="shadow-sm mb-6" - > - + + + + + 基础信息 + + } > - - - - - - - + + + + + + + + - - 视觉资源} - className="shadow-sm mb-6" - > - - - - - - - handleUpload(file, 'logoUrl')} - > - - - - - - - - - handleUpload(file, 'iconUrl')} - > - - - - - - - - - handleUpload(file, 'loginBgUrl')} - > - - - - - - + + + + 视觉资源 + + } + > + + + + + + {renderImagePreview(logoUrl, "Logo")} + handleUpload(file, "logoUrl")} + > + + + - - 合规与版权} - className="shadow-sm" - > - - - - - - - - - - - - - - - - + + + + + {renderImagePreview(iconUrl, "Icon")} + handleUpload(file, "iconUrl")} + > + + + + + + + + + {renderImagePreview(loginBgUrl, "Background")} + handleUpload(file, "loginBgUrl")} + > + + + +
+ + + + + + + 合规与版权 + + } + > + + + + + + + + + + + + + + + 页面展示预览 + +
+
+ {projectName || "iMeeting"} +
+ + 保存后将展示在系统底部与登录页底部。 + + +
+ {icpInfo || "未填写 ICP 备案号"} + {copyrightInfo || "未填写版权信息"} +
+ {footerPreview ? ( +
+ {footerPreview} +
+ ) : null} +
+
+ + + +
); } diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index c8d982c..dbfebf6 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -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(); + const { items: categories } = useDict("biz_hotword_category"); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); 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(undefined); const [searchType, setSearchType] = useState(undefined); - const [modalVisible, setModalVisible] = useState(false); const [editingId, setEditingId] = useState(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) => { - 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) => ( {text} {record.isPublic === 1 ? ( - + + + ) : ( - + + + )} - ) + ), }, { - title: '拼音', - dataIndex: 'pinyinList', - key: 'pinyinList', - render: (list: string[]) => ( - - {list?.map(p => {p})} - - ) + title: "拼音", + dataIndex: "pinyinList", + key: "pinyinList", + render: (list: string[]) => + list?.[0] ? {list[0]} : -, }, { - 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 ? 公开 : 私有 + title: "范围", + dataIndex: "isPublic", + key: "isPublic", + render: (value: number) => (value === 1 ? 公开 : 私有), }, { - title: '权重', - dataIndex: 'weight', - key: 'weight', - render: (val: number) => {val} + title: "权重", + dataIndex: "weight", + key: "weight", + render: (value: number) => {value}, }, { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (status: number) => status === 1 ? : + title: "状态", + dataIndex: "status", + key: "status", + render: (value: number) => + value === 1 ? : , }, { - 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 无权操作; + } + return ( - {canEdit ? ( - <> - - handleDelete(record.id)} - okText={t('common.confirm')} - cancelText={t('common.cancel')} - > - - - - - ) : ( - 无权操作 - )} + + handleDelete(record.id)} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + > + + ); - } - } + }, + }, ]; return ( -
- + - { + setSearchType(value); + setCurrent(1); + }} > - { + setSearchCategory(value); + setCurrent(1); + }} > - {categories.map(c => )} + {categories.map((item) => ( + + ))} - } - allowClear - onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}} - style={{ width: 180 }} + } + allowClear + onPressEnter={(e) => { + setSearchWord((e.target as HTMLInputElement).value); + setCurrent(1); + }} + style={{ width: 200 }} />