加入缓存
parent
c9a304e080
commit
a78561edb0
|
|
@ -607,4 +607,79 @@
|
|||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Upload Confirmation Modal */
|
||||
.delete-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.delete-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.delete-modal h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #1e293b;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.delete-modal p {
|
||||
margin: 0 0 2rem 0;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-actions .btn-cancel, .modal-actions .btn-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-actions .btn-cancel {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.modal-actions .btn-cancel:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-actions .btn-submit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-actions .btn-submit:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.modal-actions .btn-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ const EditMeeting = ({ user }) => {
|
|||
const [error, setError] = useState('');
|
||||
const [meeting, setMeeting] = useState(null);
|
||||
const [showUploadArea, setShowUploadArea] = useState(false);
|
||||
const [showUploadConfirm, setShowUploadConfirm] = useState(false);
|
||||
|
||||
const handleSummaryChange = useCallback((value) => {
|
||||
setFormData(prev => ({ ...prev, summary: value || '' }));
|
||||
|
|
@ -159,10 +160,10 @@ const EditMeeting = ({ user }) => {
|
|||
setError('');
|
||||
|
||||
try {
|
||||
// Upload new audio file
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append('audio_file', audioFile);
|
||||
formDataUpload.append('meeting_id', meeting_id);
|
||||
formDataUpload.append('force_replace', 'true'); // Always force replace in edit mode
|
||||
|
||||
const response = await axios.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, {
|
||||
headers: {
|
||||
|
|
@ -170,22 +171,16 @@ const EditMeeting = ({ user }) => {
|
|||
},
|
||||
});
|
||||
|
||||
// Update summary with new AI analysis result
|
||||
if (response.data.summary) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
summary: response.data.summary
|
||||
}));
|
||||
}
|
||||
|
||||
setAudioFile(null);
|
||||
setShowUploadArea(false);
|
||||
setShowUploadConfirm(false);
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('audio-file');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
} catch (err) {
|
||||
setError('上传音频文件失败,请重试');
|
||||
console.error('Upload error:', err);
|
||||
setError(err.response?.data?.detail || '上传音频文件失败,请重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
|
@ -511,7 +506,7 @@ const EditMeeting = ({ user }) => {
|
|||
{audioFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadAudio}
|
||||
onClick={() => setShowUploadConfirm(true)}
|
||||
className="upload-btn"
|
||||
disabled={isUploading}
|
||||
>
|
||||
|
|
@ -541,7 +536,6 @@ const EditMeeting = ({ user }) => {
|
|||
preview="edit"
|
||||
hideToolbar={false}
|
||||
toolbarBottom={false}
|
||||
visibleDragBar={false}
|
||||
commands={customCommands}
|
||||
extraCommands={customExtraCommands}
|
||||
autoFocus={false}
|
||||
|
|
@ -596,6 +590,31 @@ const EditMeeting = ({ user }) => {
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Confirmation Modal */}
|
||||
{showUploadConfirm && (
|
||||
<div className="delete-modal-overlay" onClick={() => setShowUploadConfirm(false)}>
|
||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认重新上传</h3>
|
||||
<p>重传音频文件将清空已有的会话转录,是否继续?</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="btn-cancel"
|
||||
onClick={() => setShowUploadConfirm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn-submit"
|
||||
onClick={handleUploadAudio}
|
||||
disabled={isUploading}
|
||||
>
|
||||
确定重传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -330,6 +330,108 @@
|
|||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 转录状态显示样式 - 一行显示 */
|
||||
.transcription-status {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.status-content-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-header-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-pending-inline,
|
||||
.status-processing-inline,
|
||||
.status-completed-inline,
|
||||
.status-failed-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-processing-inline {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.pending {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
|
||||
.status-indicator.processing {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
.status-indicator.completed {
|
||||
background-color: #10b981;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.status-indicator.failed {
|
||||
background-color: #ef4444;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-small {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill-small {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error-message-inline {
|
||||
font-size: 0.75rem;
|
||||
color: #fca5a5;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -556,7 +658,6 @@
|
|||
|
||||
.no-summary-content svg {
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-summary-content h3 {
|
||||
|
|
@ -573,17 +674,31 @@
|
|||
.generate-summary-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
min-height: 40px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.generate-summary-cta svg {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.generate-summary-cta span {
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.generate-summary-cta:hover {
|
||||
|
|
@ -617,6 +732,32 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.transcript-header h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auto-scroll-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.auto-scroll-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.edit-speakers-btn,
|
||||
.ai-summary-btn {
|
||||
display: flex;
|
||||
|
|
@ -1132,17 +1273,31 @@
|
|||
.generate-summary-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
min-height: 40px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.generate-summary-btn svg {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.generate-summary-btn span {
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.generate-summary-btn:hover:not(:disabled) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download } from 'lucide-react';
|
||||
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, Lock, Unlock } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
|
@ -28,6 +28,10 @@ const MeetingDetails = ({ user }) => {
|
|||
const [editingSpeakers, setEditingSpeakers] = useState({});
|
||||
const [speakerList, setSpeakerList] = useState([]);
|
||||
const [showTranscriptEdit, setShowTranscriptEdit] = useState(false);
|
||||
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
|
||||
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
|
||||
const [statusCheckInterval, setStatusCheckInterval] = useState(null);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(false); // 控制自动滚动
|
||||
const [editingTranscriptIndex, setEditingTranscriptIndex] = useState(-1);
|
||||
const [editingTranscripts, setEditingTranscripts] = useState({});
|
||||
const [currentSubtitle, setCurrentSubtitle] = useState('');
|
||||
|
|
@ -43,7 +47,61 @@ const MeetingDetails = ({ user }) => {
|
|||
|
||||
useEffect(() => {
|
||||
fetchMeetingDetails();
|
||||
|
||||
// Cleanup interval on unmount
|
||||
return () => {
|
||||
if (statusCheckInterval) {
|
||||
clearInterval(statusCheckInterval);
|
||||
}
|
||||
};
|
||||
}, [meeting_id]);
|
||||
|
||||
// Cleanup interval when status changes
|
||||
useEffect(() => {
|
||||
if (transcriptionStatus && !['pending', 'processing'].includes(transcriptionStatus.status)) {
|
||||
if (statusCheckInterval) {
|
||||
clearInterval(statusCheckInterval);
|
||||
setStatusCheckInterval(null);
|
||||
}
|
||||
}
|
||||
}, [transcriptionStatus, statusCheckInterval]);
|
||||
|
||||
const startStatusPolling = (taskId) => {
|
||||
// Clear existing interval
|
||||
if (statusCheckInterval) {
|
||||
clearInterval(statusCheckInterval);
|
||||
}
|
||||
|
||||
// Poll every 3 seconds
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const baseUrl = "";
|
||||
const statusResponse = await axios.get(`${baseUrl}/api/transcription/tasks/${taskId}/status`);
|
||||
const status = statusResponse.data;
|
||||
|
||||
setTranscriptionStatus(status);
|
||||
setTranscriptionProgress(status.progress || 0);
|
||||
|
||||
// Stop polling if task is completed or failed
|
||||
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
|
||||
clearInterval(interval);
|
||||
setStatusCheckInterval(null);
|
||||
|
||||
// Refresh meeting details to get updated transcript
|
||||
if (status.status === 'completed') {
|
||||
fetchMeetingDetails();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transcription status:', error);
|
||||
// Clear interval on error to prevent endless polling
|
||||
clearInterval(interval);
|
||||
setStatusCheckInterval(null);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
setStatusCheckInterval(interval);
|
||||
};
|
||||
|
||||
const fetchMeetingDetails = async () => {
|
||||
try {
|
||||
|
|
@ -58,6 +116,20 @@ const MeetingDetails = ({ user }) => {
|
|||
const response = await axios.get(`${baseUrl}${detailEndpoint}`);
|
||||
setMeeting(response.data);
|
||||
|
||||
// Handle transcription status from meeting details
|
||||
if (response.data.transcription_status) {
|
||||
setTranscriptionStatus(response.data.transcription_status);
|
||||
setTranscriptionProgress(response.data.transcription_status.progress || 0);
|
||||
|
||||
// If transcription is in progress, start polling for updates
|
||||
if (['pending', 'processing'].includes(response.data.transcription_status.status)) {
|
||||
startStatusPolling(response.data.transcription_status.task_id);
|
||||
}
|
||||
} else {
|
||||
setTranscriptionStatus(null);
|
||||
setTranscriptionProgress(0);
|
||||
}
|
||||
|
||||
// Fetch audio file if available
|
||||
try {
|
||||
const audioResponse = await axios.get(`${baseUrl}${audioEndpoint}`);
|
||||
|
|
@ -168,8 +240,8 @@ const MeetingDetails = ({ user }) => {
|
|||
const currentIndex = transcript.findIndex(item => item.segment_id === currentSegment.segment_id);
|
||||
setCurrentHighlightIndex(currentIndex);
|
||||
|
||||
// 滚动到对应的转录条目
|
||||
if (currentIndex !== -1 && transcriptRefs.current[currentIndex]) {
|
||||
// 滚动到对应的转录条目(仅在启用自动滚动时)
|
||||
if (autoScrollEnabled && currentIndex !== -1 && transcriptRefs.current[currentIndex]) {
|
||||
transcriptRefs.current[currentIndex].scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
|
|
@ -658,6 +730,53 @@ const MeetingDetails = ({ user }) => {
|
|||
您的浏览器不支持音频播放。
|
||||
</audio>
|
||||
|
||||
{/* 转录状态显示 */}
|
||||
{transcriptionStatus && (
|
||||
<div className="transcription-status">
|
||||
<div className="status-content-inline">
|
||||
<div className="status-header-inline">
|
||||
<Brain size={16} />
|
||||
<span>语音转录进度:</span>
|
||||
</div>
|
||||
{transcriptionStatus.status === 'pending' && (
|
||||
<div className="status-pending-inline">
|
||||
<div className="status-indicator pending"></div>
|
||||
<span>等待处理中...</span>
|
||||
</div>
|
||||
)}
|
||||
{transcriptionStatus.status === 'processing' && (
|
||||
<div className="status-processing-inline">
|
||||
<div className="status-indicator processing"></div>
|
||||
<span>转录进行中</span>
|
||||
<div className="progress-bar-small">
|
||||
<div
|
||||
className="progress-fill-small"
|
||||
style={{ width: `${transcriptionProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="progress-text">{transcriptionProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{transcriptionStatus.status === 'completed' && (
|
||||
<div className="status-completed-inline">
|
||||
<div className="status-indicator completed"></div>
|
||||
<span>转录已完成</span>
|
||||
<Sparkles size={14} />
|
||||
</div>
|
||||
)}
|
||||
{transcriptionStatus.status === 'failed' && (
|
||||
<div className="status-failed-inline">
|
||||
<div className="status-indicator failed"></div>
|
||||
<span>转录失败</span>
|
||||
{transcriptionStatus.error_message && (
|
||||
<span className="error-message-inline">({transcriptionStatus.error_message})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="player-controls">
|
||||
<button className="play-button" onClick={handlePlayPause}>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||
|
|
@ -778,7 +897,17 @@ const MeetingDetails = ({ user }) => {
|
|||
{/* Transcript Sidebar */}
|
||||
<div className="transcript-sidebar">
|
||||
<div className="transcript-header">
|
||||
<h3><MessageCircle size={20} /> 对话转录</h3>
|
||||
<h3>
|
||||
<MessageCircle size={20} />
|
||||
对话转录
|
||||
<button
|
||||
className="auto-scroll-btn"
|
||||
onClick={() => setAutoScrollEnabled(!autoScrollEnabled)}
|
||||
title={autoScrollEnabled ? "关闭自动滚动" : "开启自动滚动"}
|
||||
>
|
||||
{autoScrollEnabled ? <Lock size={16} /> : <Unlock size={16} />}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="transcript-controls">
|
||||
{isCreator && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default defineConfig({
|
|||
server: {
|
||||
host: true, // Optional: Allows the server to be accessible externally
|
||||
port: 5173, // Optional: Specify a port if needed
|
||||
allowedHosts: ['6fc3f0b0.r3.cpolar.cn'], // Add the problematic hostname here
|
||||
allowedHosts: ['imeeting.unisspace.com'], // Add the problematic hostname here
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000', // 后端服务地址
|
||||
|
|
|
|||
Loading…
Reference in New Issue