diff --git a/backend/app/services/admin_dashboard_service.py b/backend/app/services/admin_dashboard_service.py index 40e8029..88e3c62 100644 --- a/backend/app/services/admin_dashboard_service.py +++ b/backend/app/services/admin_dashboard_service.py @@ -319,6 +319,7 @@ async def monitor_tasks( m.title as meeting_title, t.status, t.progress, + t.result, t.error_message, t.created_at, t.completed_at, diff --git a/backend/app/services/async_meeting_service.py b/backend/app/services/async_meeting_service.py index 28958fd..a75ff77 100644 --- a/backend/app/services/async_meeting_service.py +++ b/backend/app/services/async_meeting_service.py @@ -11,7 +11,7 @@ from typing import Optional, Dict, Any, List from pathlib import Path import redis -from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR +from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR, BASE_DIR from app.core.database import get_db_connection from app.services.async_transcription_service import AsyncTranscriptionService from app.services.background_task_runner import KeyedBackgroundTaskRunner @@ -164,7 +164,7 @@ class AsyncMeetingService: # 6. 导出MD文件到音频同目录 self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...") - md_path = self._export_summary_md(meeting_id, summary_content) + md_path = self._export_summary_md(meeting_id, summary_content, task_id=task_id) # 7. 任务完成,result保存MD文件路径 self._update_task_in_db(task_id, 'completed', 100, result=md_path) @@ -337,8 +337,8 @@ class AsyncMeetingService: print(f"使用模型 {model_code} 调用失败: {e}") return None - def _export_summary_md(self, meeting_id: int, summary_content: str) -> Optional[str]: - """将总结内容导出为MD文件,保存到音频同目录,返回文件路径""" + def _export_summary_md(self, meeting_id: int, summary_content: str, task_id: Optional[str] = None) -> Optional[str]: + """将总结内容导出为MD文件,保存到音频同目录,返回 /uploads/... 相对路径""" try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -348,16 +348,9 @@ class AsyncMeetingService: audio = cursor.fetchone() title = meeting['title'] if meeting else f"meeting_{meeting_id}" - # 始终以 AUDIO_DIR 为基准,避免数据库中的绝对路径指向不可写目录 if audio and audio.get('file_path'): - audio_path = Path(audio['file_path']) - # 提取 meeting_id 层级的子目录(如 "226" 或 "226/sub") - try: - rel = audio_path.relative_to(AUDIO_DIR) - md_dir = AUDIO_DIR / rel.parent - except ValueError: - # file_path 不在 AUDIO_DIR 下(如 Docker 绝对路径),取最后一级目录名 - md_dir = AUDIO_DIR / audio_path.parent.name + audio_path = BASE_DIR / str(audio['file_path']).lstrip('/') + md_dir = audio_path.parent else: md_dir = AUDIO_DIR / str(meeting_id) @@ -365,11 +358,15 @@ class AsyncMeetingService: safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip() if not safe_title: safe_title = f"meeting_{meeting_id}" - md_path = md_dir / f"{safe_title}_总结.md" + timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S") + task_suffix = "" + if task_id: + task_suffix = f"_{str(task_id).replace('-', '')[:8]}" + md_path = md_dir / f"{safe_title}_总结_{timestamp_suffix}{task_suffix}.md" md_path.write_text(summary_content, encoding='utf-8') - md_path_str = str(md_path) - print(f"总结MD文件已保存: {md_path_str}") - return md_path_str + relative_md_path = "/" + str(md_path.relative_to(BASE_DIR)).replace("\\", "/") + print(f"总结MD文件已保存: {relative_md_path}") + return relative_md_path except Exception as e: print(f"导出总结MD文件失败: {e}") return None diff --git a/backend/sql/migrations/normalize_llm_task_result_paths.sql b/backend/sql/migrations/normalize_llm_task_result_paths.sql new file mode 100644 index 0000000..f28667c --- /dev/null +++ b/backend/sql/migrations/normalize_llm_task_result_paths.sql @@ -0,0 +1,9 @@ +-- 将 llm_tasks.result 统一为 /uploads/... 相对路径 +-- 仅处理能明确识别出 /uploads/ 前缀的历史绝对路径记录 + +UPDATE `llm_tasks` +SET `result` = SUBSTRING(`result`, LOCATE('/uploads/', `result`)) +WHERE `result` IS NOT NULL + AND `result` <> '' + AND `result` NOT LIKE '/uploads/%' + AND LOCATE('/uploads/', `result`) > 0; diff --git a/deploy.md b/deploy.md index e4881b4..a22cfec 100644 --- a/deploy.md +++ b/deploy.md @@ -7,4 +7,6 @@ # 升级前确认 + 后端运行环境需提供 `ffmpeg` 与 `ffprobe` + 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql` ++ 本次数据库升级还需执行 `backend/sql/migrations/normalize_llm_task_result_paths.sql` + 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列 ++ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致 diff --git a/frontend/src/hooks/useAdminDashboardPage.js b/frontend/src/hooks/useAdminDashboardPage.js index 12f2ea9..f773af2 100644 --- a/frontend/src/hooks/useAdminDashboardPage.js +++ b/frontend/src/hooks/useAdminDashboardPage.js @@ -27,6 +27,7 @@ export default function useAdminDashboardPage() { const [showMeetingModal, setShowMeetingModal] = useState(false); const [meetingDetails, setMeetingDetails] = useState(null); const [meetingLoading, setMeetingLoading] = useState(false); + const [selectedTaskRecord, setSelectedTaskRecord] = useState(null); useEffect(() => { mountedRef.current = true; @@ -155,9 +156,10 @@ export default function useAdminDashboardPage() { }); }; - const handleViewMeeting = async (meetingId) => { + const handleViewMeeting = async (meetingId, taskRecord = null) => { setMeetingLoading(true); setShowMeetingModal(true); + setSelectedTaskRecord(taskRecord); try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); if (response.code === '200') { @@ -190,35 +192,45 @@ export default function useAdminDashboardPage() { } }; - const handleDownloadAudio = async (meetingId, audioFilePath) => { - try { - const response = await fetch(buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meetingId)}/stream`), { - credentials: 'include', - }); - if (!response.ok) { - throw new Error(`audio download failed: ${response.status}`); - } + const downloadResourceByPath = useCallback((resourcePath, fallbackFileName) => { + const normalizedPath = typeof resourcePath === 'string' ? resourcePath.trim() : ''; + if (!normalizedPath) { + message.error('文件路径不存在'); + return; + } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - const fileNameFromPath = audioFilePath?.split('/').pop(); - const fallbackExtension = fileNameFromPath?.includes('.') ? '' : '.mp3'; - link.href = url; - link.download = fileNameFromPath || `meeting_audio_${meetingId}${fallbackExtension}`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + const href = buildApiUrl(normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`); + const fileNameFromPath = normalizedPath.split('/').pop(); + const link = document.createElement('a'); + link.href = href; + link.download = fileNameFromPath || fallbackFileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [message]); + + const handleDownloadAudio = (meetingId, audioFilePath) => { + try { + downloadResourceByPath(audioFilePath, `meeting_audio_${meetingId}.mp3`); } catch (error) { console.error('下载音频失败:', error); message.error('下载音频失败'); } }; + const handleDownloadSummaryResult = (taskId, resultPath) => { + try { + downloadResourceByPath(resultPath, `summary_${taskId}.md`); + } catch (error) { + console.error('下载总结结果失败:', error); + message.error('下载总结结果失败'); + } + }; + const closeMeetingModal = () => { setShowMeetingModal(false); setMeetingDetails(null); + setSelectedTaskRecord(null); }; const taskCompletionRate = useMemo(() => { @@ -246,12 +258,14 @@ export default function useAdminDashboardPage() { showMeetingModal, meetingDetails, meetingLoading, + selectedTaskRecord, fetchAllData, fetchOnlineUsers, handleKickUser, handleViewMeeting, handleDownloadTranscript, handleDownloadAudio, + handleDownloadSummaryResult, closeMeetingModal, taskCompletionRate, }; diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index daae36f..4f4c9e4 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -86,11 +86,13 @@ const AdminDashboard = () => { showMeetingModal, meetingDetails, meetingLoading, + selectedTaskRecord, fetchAllData, handleKickUser, handleViewMeeting, handleDownloadTranscript, handleDownloadAudio, + handleDownloadSummaryResult, closeMeetingModal, taskCompletionRate, } = useAdminDashboardPage(); @@ -172,7 +174,7 @@ const AdminDashboard = () => { render: (text, record) => ( {record.meeting_id ? ( - } onClick={() => handleViewMeeting(record.meeting_id)} /> + } onClick={() => handleViewMeeting(record.meeting_id, record)} /> ) : null} {text || '-'} @@ -407,25 +409,38 @@ const AdminDashboard = () => { {meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'} {meetingDetails.prompt_name || '默认模版'} - - - {formatAudioDuration(meetingDetails.audio_duration)} - {meetingDetails.audio_file_path ? ( - - ) : null} - - + {selectedTaskRecord?.task_type !== 'summary' ? ( + + + {formatAudioDuration(meetingDetails.audio_duration)} + {meetingDetails.audio_file_path ? ( + + ) : null} + + + ) : null} - } onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}> - 下载转录结果 (JSON) - + {selectedTaskRecord?.task_type === 'summary' ? ( + } + onClick={() => handleDownloadSummaryResult(selectedTaskRecord.task_id, selectedTaskRecord.result)} + > + 下载总结结果(md) + + ) : ( + } onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}> + 下载转录结果 (JSON) + + )} ) : (