v1.1.1
parent
861d7e3463
commit
27fa9317c5
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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/...` 相对路径,与音频文件路径保持一致
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<Space>
|
||||
{record.meeting_id ? (
|
||||
<ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id)} />
|
||||
<ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id, record)} />
|
||||
) : null}
|
||||
<span>{text || '-'}</span>
|
||||
</Space>
|
||||
|
|
@ -407,25 +409,38 @@ const AdminDashboard = () => {
|
|||
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item>
|
||||
<Descriptions.Item label="音频信息">
|
||||
<Space size="middle">
|
||||
<span>{formatAudioDuration(meetingDetails.audio_duration)}</span>
|
||||
{meetingDetails.audio_file_path ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => handleDownloadAudio(meetingDetails.meeting_id, meetingDetails.audio_file_path)}
|
||||
>
|
||||
下载音频
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{selectedTaskRecord?.task_type !== 'summary' ? (
|
||||
<Descriptions.Item label="音频信息">
|
||||
<Space size="middle">
|
||||
<span>{formatAudioDuration(meetingDetails.audio_duration)}</span>
|
||||
{meetingDetails.audio_file_path ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => handleDownloadAudio(meetingDetails.meeting_id, meetingDetails.audio_file_path)}
|
||||
>
|
||||
下载音频
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="操作">
|
||||
<ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
|
||||
下载转录结果 (JSON)
|
||||
</ActionButton>
|
||||
{selectedTaskRecord?.task_type === 'summary' ? (
|
||||
<ActionButton
|
||||
tone="view"
|
||||
variant="textLg"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleDownloadSummaryResult(selectedTaskRecord.task_id, selectedTaskRecord.result)}
|
||||
>
|
||||
下载总结结果(md)
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
|
||||
下载转录结果 (JSON)
|
||||
</ActionButton>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in New Issue