feat: 添加关键词管理功能和会议转录编辑功能

- 在 `MeetingDetail` 页面中添加关键词选择和一键加入热词的功能
- 增加会议转录的编辑和保存功能
- 更新后端接口和相关服务,支持更新会议转录内容
dev_na
chenhao 2026-03-27 13:50:01 +08:00
parent 8dbed4c8e6
commit ffc19fa572
6 changed files with 407 additions and 7 deletions

View File

@ -13,6 +13,7 @@ import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand; import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand;
import com.imeeting.dto.biz.UpdateMeetingSummaryCommand; import com.imeeting.dto.biz.UpdateMeetingSummaryCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingCommandService;
@ -244,6 +245,20 @@ public class MeetingController {
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@PutMapping("/{id}/transcripts/{transcriptId}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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") @PutMapping("/{id}/participants")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> updateParticipants(@PathVariable Long id, public ApiResponse<Boolean> updateParticipants(@PathVariable Long id,

View File

@ -5,6 +5,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import java.util.List; import java.util.List;
@ -21,6 +22,8 @@ public interface MeetingCommandService {
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void updateMeetingTranscript(UpdateMeetingTranscriptCommand command);
void updateMeetingBasic(UpdateMeetingBasicCommand command); void updateMeetingBasic(UpdateMeetingBasicCommand command);
void updateMeetingParticipants(Long meetingId, String participants); void updateMeetingParticipants(Long meetingId, String participants);

View File

@ -7,6 +7,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
@ -173,6 +174,23 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
.set(label != null, MeetingTranscript::getSpeakerLabel, label)); .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<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, command.getMeetingId())
.eq(MeetingTranscript::getId, command.getTranscriptId())
.set(MeetingTranscript::getContent, content));
if (updated <= 0) {
throw new RuntimeException("Transcript not found");
}
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateMeetingBasic(UpdateMeetingBasicCommand command) { public void updateMeetingBasic(UpdateMeetingBasicCommand command) {

View File

@ -24,6 +24,7 @@ export interface HotWordDTO {
category?: string; category?: string;
weight: number; weight: number;
status: number; status: number;
isPublic?: number;
remark?: string; remark?: string;
} }

View File

@ -140,6 +140,12 @@ export interface MeetingSpeakerUpdateDTO {
label: string; label: string;
} }
export interface MeetingTranscriptUpdateDTO {
meetingId: number;
transcriptId: number;
content: string;
}
export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => { export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => {
return http.put<any, { code: string; data: boolean; msg: string }>( return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/speaker", "/api/biz/meeting/speaker",
@ -147,6 +153,13 @@ export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => {
); );
}; };
export const updateMeetingTranscript = (params: MeetingTranscriptUpdateDTO) => {
return http.put<any, { code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${params.meetingId}/transcripts/${params.transcriptId}`,
params
);
};
export interface MeetingResummaryDTO { export interface MeetingResummaryDTO {
meetingId: number; meetingId: number;
summaryModelId: number; summaryModelId: number;

View File

@ -5,6 +5,7 @@ import {
Breadcrumb, Breadcrumb,
Button, Button,
Card, Card,
Checkbox,
Col, Col,
Divider, Divider,
Drawer, Drawer,
@ -49,10 +50,12 @@ import {
MeetingVO, MeetingVO,
reSummary, reSummary,
updateMeetingBasic, updateMeetingBasic,
updateMeetingTranscript,
updateMeetingSummary, updateMeetingSummary,
updateSpeakerInfo, updateSpeakerInfo,
} from '../../api/business/meeting'; } from '../../api/business/meeting';
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
import { getHotWordPage, saveHotWord } from '../../api/business/hotword';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { listUsers } from '../../api'; import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict'; import { useDict } from '../../hooks/useDict';
@ -428,6 +431,11 @@ const MeetingDetail: React.FC = () => {
const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters'); const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters');
const [expandKeywords, setExpandKeywords] = useState(false); const [expandKeywords, setExpandKeywords] = useState(false);
const [expandSummary, setExpandSummary] = useState(false); const [expandSummary, setExpandSummary] = useState(false);
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [transcriptDraft, setTranscriptDraft] = useState('');
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0); const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false); const [audioPlaying, setAudioPlaying] = useState(false);
@ -473,6 +481,10 @@ const MeetingDetail: React.FC = () => {
loadUsers(); loadUsers();
}, [id]); }, [id]);
useEffect(() => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
}, [analysis.keywords]);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return undefined; 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<HTMLDivElement>) => {
if (event.key === 'Escape') {
handleCancelEditTranscript();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault();
void handleSaveTranscript(item);
}
};
const seekTo = (timeMs: number) => { const seekTo = (timeMs: number) => {
if (!audioRef.current) return; if (!audioRef.current) return;
audioRef.current.currentTime = timeMs / 1000; audioRef.current.currentTime = timeMs / 1000;
@ -783,6 +1008,18 @@ const MeetingDetail: React.FC = () => {
<div className="summary-title"> <div className="summary-title">
<RobotOutlined /> <RobotOutlined />
<span></span> <span></span>
{isOwner && analysis.keywords.length > 0 && (
<Button
size="small"
type="primary"
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
</Button>
)}
</div> </div>
<div className="summary-actions"> <div className="summary-actions">
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span> <span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
@ -797,7 +1034,12 @@ const MeetingDetail: React.FC = () => {
<> <>
<div className="record-tags"> <div className="record-tags">
{visibleKeywords.map((tag) => ( {visibleKeywords.map((tag) => (
<span key={tag} className="tag">{tag}</span> <label key={tag} className={`tag selectable-tag ${selectedKeywords.includes(tag) ? 'selected' : ''}`}>
{isOwner && (
<Checkbox checked={selectedKeywords.includes(tag)} onChange={(event) => handleKeywordToggle(tag, event.target.checked)} />
)}
<span>{tag}</span>
</label>
))} ))}
</div> </div>
{analysis.keywords.length > 9 && ( {analysis.keywords.length > 9 && (
@ -973,7 +1215,50 @@ const MeetingDetail: React.FC = () => {
</Tag> </Tag>
)} )}
</div> </div>
<div className="transcript-bubble">{item.content}</div> {editingTranscriptId === item.id ? (
<div
className="transcript-bubble transcript-bubble-editing"
onClick={(event) => event.stopPropagation()}
>
<Input.TextArea
autoFocus
value={transcriptDraft}
style={{ width: '100%' }}
onChange={(event) => setTranscriptDraft(event.target.value)}
onKeyDown={(event) => handleTranscriptDraftKeyDown(item, event as unknown as React.KeyboardEvent<HTMLDivElement>)}
onBlur={(event) => {
event.stopPropagation();
void handleSaveTranscript(item, event.target.value);
}}
autoSize={{ minRows: 1, maxRows: 8 }}
className="transcript-bubble-input"
bordered={false}
/>
{false && false && <div className="transcript-bubble-actions">
<Text type="secondary" className="transcript-bubble-hint">
Ctrl+Enter Esc
</Text>
<Button size="small" onClick={handleCancelEditTranscript}>
</Button>
<Button
size="small"
type="primary"
loading={savingTranscriptId === item.id}
onClick={() => handleSaveTranscript(item)}
>
</Button>
</div>}
</div>
) : (
<div
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
onDoubleClick={isOwner ? (event) => handleStartEditTranscript(item, event) : undefined}
>
{item.content}
</div>
)}
</div> </div>
</List.Item> </List.Item>
)} )}
@ -1178,6 +1463,17 @@ const MeetingDetail: React.FC = () => {
font-size: 13px; font-size: 13px;
font-weight: 600; 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 { .summary-copy {
color: #465072; color: #465072;
font-size: 14px; font-size: 14px;
@ -1415,13 +1711,13 @@ const MeetingDetail: React.FC = () => {
background: rgba(218, 223, 243, 0.96); background: rgba(218, 223, 243, 0.96);
} }
.transcript-entry { .transcript-entry {
direction: ltr;
justify-self: start; justify-self: start;
text-align: left; text-align: left;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: stretch;
gap: 10px; gap: 10px;
width: 100%;
min-width: 0; min-width: 0;
} }
.transcript-meta { .transcript-meta {
@ -1443,9 +1739,11 @@ const MeetingDetail: React.FC = () => {
cursor: pointer; cursor: pointer;
} }
.transcript-bubble { .transcript-bubble {
display: inline-block; display: block;
width: auto; align-self: stretch;
max-width: min(100%, 860px); width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 14px 18px; padding: 14px 18px;
border-radius: 16px; border-radius: 16px;
background: #ffffff; background: #ffffff;
@ -1455,6 +1753,58 @@ const MeetingDetail: React.FC = () => {
line-height: 1.86; line-height: 1.86;
white-space: pre-wrap; 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 { .transcript-player {
position: sticky; position: sticky;
bottom: 0; bottom: 0;