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)
+
+ )}
) : (