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 51b48fa..9023843 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -13,6 +13,7 @@ import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand; import com.imeeting.dto.biz.UpdateMeetingSummaryCommand; +import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import com.imeeting.entity.biz.Meeting; import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingCommandService; @@ -244,6 +245,20 @@ public class MeetingController { return ApiResponse.ok(true); } + @PutMapping("/{id}/transcripts/{transcriptId}") + @PreAuthorize("isAuthenticated()") + public ApiResponse updateTranscript(@PathVariable Long id, + @PathVariable Long transcriptId, + @RequestBody UpdateMeetingTranscriptCommand command) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + command.setMeetingId(id); + command.setTranscriptId(transcriptId); + meetingCommandService.updateMeetingTranscript(command); + return ApiResponse.ok(true); + } + @PutMapping("/{id}/participants") @PreAuthorize("isAuthenticated()") public ApiResponse updateParticipants(@PathVariable Long id, diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index 8246c46..74c768f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -5,6 +5,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.UpdateMeetingBasicCommand; +import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import java.util.List; @@ -21,6 +22,8 @@ public interface MeetingCommandService { void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); + void updateMeetingTranscript(UpdateMeetingTranscriptCommand command); + void updateMeetingBasic(UpdateMeetingBasicCommand command); void updateMeetingParticipants(Long meetingId, String participants); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index e762e66..0945dc0 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -7,6 +7,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.UpdateMeetingBasicCommand; +import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.Meeting; @@ -173,6 +174,23 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { .set(label != null, MeetingTranscript::getSpeakerLabel, label)); } + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMeetingTranscript(UpdateMeetingTranscriptCommand command) { + String content = command.getContent() == null ? "" : command.getContent().trim(); + if (content.isEmpty()) { + throw new RuntimeException("Transcript content cannot be empty"); + } + + int updated = transcriptMapper.update(null, new LambdaUpdateWrapper() + .eq(MeetingTranscript::getMeetingId, command.getMeetingId()) + .eq(MeetingTranscript::getId, command.getTranscriptId()) + .set(MeetingTranscript::getContent, content)); + if (updated <= 0) { + throw new RuntimeException("Transcript not found"); + } + } + @Override @Transactional(rollbackFor = Exception.class) public void updateMeetingBasic(UpdateMeetingBasicCommand command) { diff --git a/frontend/src/api/business/hotword.ts b/frontend/src/api/business/hotword.ts index bd90041..662bf2a 100644 --- a/frontend/src/api/business/hotword.ts +++ b/frontend/src/api/business/hotword.ts @@ -24,6 +24,7 @@ export interface HotWordDTO { category?: string; weight: number; status: number; + isPublic?: number; remark?: string; } diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index f6219de..f16bfa9 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -140,6 +140,12 @@ export interface MeetingSpeakerUpdateDTO { label: string; } +export interface MeetingTranscriptUpdateDTO { + meetingId: number; + transcriptId: number; + content: string; +} + export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => { return http.put( "/api/biz/meeting/speaker", @@ -147,6 +153,13 @@ export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => { ); }; +export const updateMeetingTranscript = (params: MeetingTranscriptUpdateDTO) => { + return http.put( + `/api/biz/meeting/${params.meetingId}/transcripts/${params.transcriptId}`, + params + ); +}; + export interface MeetingResummaryDTO { meetingId: number; summaryModelId: number; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 9166a0e..57bbb10 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -5,6 +5,7 @@ import { Breadcrumb, Button, Card, + Checkbox, Col, Divider, Drawer, @@ -49,10 +50,12 @@ import { MeetingVO, reSummary, updateMeetingBasic, + updateMeetingTranscript, updateMeetingSummary, updateSpeakerInfo, } from '../../api/business/meeting'; import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; +import { getHotWordPage, saveHotWord } from '../../api/business/hotword'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { listUsers } from '../../api'; import { useDict } from '../../hooks/useDict'; @@ -428,6 +431,11 @@ const MeetingDetail: React.FC = () => { const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters'); const [expandKeywords, setExpandKeywords] = useState(false); const [expandSummary, setExpandSummary] = useState(false); + const [selectedKeywords, setSelectedKeywords] = useState([]); + const [addingHotwords, setAddingHotwords] = useState(false); + const [editingTranscriptId, setEditingTranscriptId] = useState(null); + const [transcriptDraft, setTranscriptDraft] = useState(''); + const [savingTranscriptId, setSavingTranscriptId] = useState(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); const [audioPlaying, setAudioPlaying] = useState(false); @@ -473,6 +481,10 @@ const MeetingDetail: React.FC = () => { loadUsers(); }, [id]); + useEffect(() => { + setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item))); + }, [analysis.keywords]); + useEffect(() => { const audio = audioRef.current; if (!audio) return undefined; @@ -604,6 +616,219 @@ const MeetingDetail: React.FC = () => { } }; + const handleKeywordToggle = (keyword: string, checked: boolean) => { + setSelectedKeywords((current) => { + if (checked) { + return current.includes(keyword) ? current : [...current, keyword]; + } + return current.filter((item) => item !== keyword); + }); + }; + + /* + const handleAddSelectedHotwords = async () => { + const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); + if (!keywords.length) { + message.warning('请先勾选关键词'); + return; + } + + setAddingHotwords(true); + try { + const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' }); + const existingWords = new Set( + (existingRes.data?.data?.records || []) + .map((item) => item.word?.trim()) + .filter(Boolean), + ); + const toCreate = keywords.filter((item) => !existingWords.has(item)); + + if (!toCreate.length) { + message.info('所选关键词已全部存在于热词库'); + return; + } + + await Promise.all( + toCreate.map((word) => + saveHotWord({ + word, + pinyinList: [], + matchStrategy: 1, + category: '', + weight: 2, + status: 1, + isPublic: 0, + remark: meeting ? `来源于会议《${meeting.title}》` : '来源于会议关键词', + }), + ), + ); + + const skippedCount = keywords.length - toCreate.length; + message.success(skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`); + setSelectedKeywords([]); + } catch (error) { + console.error(error); + } finally { + setAddingHotwords(false); + } + }; + + }; + */ + + /* + const handleAddSelectedHotwords = async () => { + const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); + if (!keywords.length) { + message.warning('请先勾选关键词'); + return; + } + + setAddingHotwords(true); + try { + const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' }); + const existingWords = new Set( + (existingRes.data?.data?.records || []) + .map((item) => item.word?.trim()) + .filter(Boolean), + ); + const toCreate = keywords.filter((item) => !existingWords.has(item)); + + if (!toCreate.length) { + message.info('所选关键词已全部存在于热词库'); + return; + } + + await Promise.all( + toCreate.map((word) => + saveHotWord({ + word, + pinyinList: [], + matchStrategy: 1, + category: '', + weight: 2, + status: 1, + isPublic: 0, + remark: meeting ? `来源于会议《${meeting.title}》` : '来源于会议关键词', + }), + ), + ); + + const skippedCount = keywords.length - toCreate.length; + message.success(skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`); + setSelectedKeywords([]); + } catch (error) { + console.error(error); + } finally { + setAddingHotwords(false); + } + }; + */ + + const handleAddSelectedHotwords = async () => { + const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); + if (!keywords.length) { + message.warning('Please select keywords first'); + return; + } + + setAddingHotwords(true); + try { + const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' }); + const existingWords = new Set( + (existingRes.data?.data?.records || []) + .map((item) => item.word?.trim()) + .filter(Boolean), + ); + const toCreate = keywords.filter((item) => !existingWords.has(item)); + + if (!toCreate.length) { + message.info('Selected keywords already exist in hot words'); + return; + } + + await Promise.all( + toCreate.map((word) => + saveHotWord({ + word, + pinyinList: [], + matchStrategy: 1, + category: '', + weight: 2, + status: 1, + isPublic: 0, + remark: meeting ? `From meeting: ${meeting.title}` : 'From meeting keywords', + }), + ), + ); + + const skippedCount = keywords.length - toCreate.length; + message.success( + skippedCount > 0 + ? `Added ${toCreate.length} hot words, skipped ${skippedCount} duplicates` + : `Added ${toCreate.length} hot words`, + ); + setSelectedKeywords([]); + } catch (error) { + console.error(error); + } finally { + setAddingHotwords(false); + } + }; + + const handleStartEditTranscript = (item: MeetingTranscriptVO, event: React.MouseEvent) => { + event.stopPropagation(); + setEditingTranscriptId(item.id); + setTranscriptDraft(item.content || ''); + }; + + const handleCancelEditTranscript = (event?: React.SyntheticEvent) => { + event?.stopPropagation(); + setEditingTranscriptId(null); + setTranscriptDraft(''); + }; + + const handleSaveTranscript = async (item: MeetingTranscriptVO, nextContent?: string) => { + const content = (nextContent ?? transcriptDraft).trim(); + if (!content) { + message.warning('转录内容不能为空'); + return; + } + + if (!meeting) return; + if (content === (item.content || '').trim()) { + handleCancelEditTranscript(); + return; + } + + setSavingTranscriptId(item.id); + try { + await updateMeetingTranscript({ + meetingId: meeting.id, + transcriptId: item.id, + content, + }); + message.success('原文已更新,如需同步摘要请重新总结'); + handleCancelEditTranscript(); + await fetchData(meeting.id); + } catch (error) { + console.error(error); + } finally { + setSavingTranscriptId(null); + } + }; + + const handleTranscriptDraftKeyDown = (item: MeetingTranscriptVO, event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleCancelEditTranscript(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + void handleSaveTranscript(item); + } + }; + const seekTo = (timeMs: number) => { if (!audioRef.current) return; audioRef.current.currentTime = timeMs / 1000; @@ -783,6 +1008,18 @@ const MeetingDetail: React.FC = () => {
智能速览 + {isOwner && analysis.keywords.length > 0 && ( + + )}
{hasAnalysis ? '已生成' : '待生成'} @@ -797,7 +1034,12 @@ const MeetingDetail: React.FC = () => { <>
{visibleKeywords.map((tag) => ( - {tag} + ))}
{analysis.keywords.length > 9 && ( @@ -973,7 +1215,50 @@ const MeetingDetail: React.FC = () => { )}
-
{item.content}
+ {editingTranscriptId === item.id ? ( +
event.stopPropagation()} + > + setTranscriptDraft(event.target.value)} + onKeyDown={(event) => handleTranscriptDraftKeyDown(item, event as unknown as React.KeyboardEvent)} + onBlur={(event) => { + event.stopPropagation(); + void handleSaveTranscript(item, event.target.value); + }} + autoSize={{ minRows: 1, maxRows: 8 }} + className="transcript-bubble-input" + bordered={false} + /> + {false && false &&
+ + Ctrl+Enter 保存,Esc 取消 + + + +
} +
+ ) : ( +
handleStartEditTranscript(item, event) : undefined} + > + {item.content} +
+ )} )} @@ -1178,6 +1463,17 @@ const MeetingDetail: React.FC = () => { font-size: 13px; font-weight: 600; } + .selectable-tag { + border: 1px solid transparent; + cursor: pointer; + } + .selectable-tag.selected { + border-color: rgba(106, 114, 255, 0.28); + background: rgba(107, 115, 255, 0.14); + } + .selectable-tag .ant-checkbox { + margin-inline-end: 2px; + } .summary-copy { color: #465072; font-size: 14px; @@ -1415,13 +1711,13 @@ const MeetingDetail: React.FC = () => { background: rgba(218, 223, 243, 0.96); } .transcript-entry { - direction: ltr; justify-self: start; text-align: left; display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; gap: 10px; + width: 100%; min-width: 0; } .transcript-meta { @@ -1443,9 +1739,11 @@ const MeetingDetail: React.FC = () => { cursor: pointer; } .transcript-bubble { - display: inline-block; - width: auto; - max-width: min(100%, 860px); + display: block; + align-self: stretch; + width: 100%; + max-width: 100%; + box-sizing: border-box; padding: 14px 18px; border-radius: 16px; background: #ffffff; @@ -1455,6 +1753,58 @@ const MeetingDetail: React.FC = () => { line-height: 1.86; white-space: pre-wrap; } + .transcript-bubble.editable { + cursor: text; + } + .transcript-bubble.editable:hover { + border-color: rgba(100, 112, 255, 0.24); + } + .transcript-bubble-editing { + display: block; + align-self: stretch; + width: 100%; + max-width: 100%; + box-sizing: border-box; + outline: none; + } + .transcript-bubble-input { + display: block; + width: 100% !important; + max-width: 100%; + } + .transcript-bubble-input .ant-input-affix-wrapper, + .transcript-bubble-input .ant-input-textarea, + .transcript-bubble-editing .ant-input-textarea, + .transcript-bubble-editing .ant-input-textarea-show-count { + display: block; + width: 100% !important; + max-width: 100%; + } + .transcript-bubble-input .ant-input { + display: block; + width: 100% !important; + min-height: 1.86em; + padding: 0; + border: 0; + background: transparent; + color: #3f496a; + line-height: 1.86; + white-space: pre-wrap; + resize: none; + box-shadow: none; + direction: ltr; + text-align: left; + } + .transcript-bubble-input .ant-input:focus, + .transcript-bubble-input .ant-input:focus-within { + box-shadow: none; + } + .transcript-bubble-actions { + display: none; + } + .transcript-bubble-hint { + display: none; + } .transcript-player { position: sticky; bottom: 0;