加入缓存

main
mula.liu 2025-08-28 16:02:34 +08:00
parent c9a304e080
commit a78561edb0
5 changed files with 400 additions and 22 deletions

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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) {

View File

@ -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 && (
<>

View File

@ -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', // 后端服务地址