From bf8e50c9838b3a31a35e65982473bd1d9a0a3cc3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 15:00:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=BF=9B=E5=BA=A6=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_LOG.md | 11 + NEXT_STEPS.md | 7 + app/services/task_service.py | 216 +++++++++++++++++- app/static/css/styles.css | 81 +++++++ app/static/js/app.js | 39 +++- app/templates/task_detail.html | 22 +- docs/UI_REFERENCE.md | 9 + .../2026-08-26-task-progress-live-sync.md | 60 +++++ tests/test_auto_pipeline.py | 103 +++++++++ 9 files changed, 531 insertions(+), 17 deletions(-) create mode 100644 docs/agent_tasks/2026-08-26-task-progress-live-sync.md diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 0fa10d6..e0375c9 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,16 @@ # Development Log +## 2026-08-26 任务详情实时进度与日志统一 + +- `/api/tasks/{task_id}/live-status` 扩展为任务详情唯一实时快照:同一次响应返回主任务状态、活动 Workflow Job、转写细分进度、最新运行日志、候选/输出数量和发布任务汇总。 +- 手动任务不再被 `auto_mode` 条件排除;手动转写、手动 AI、后台切片及全自动流程都能依据活动 Job 或运行状态持续每 3 秒局部刷新,切回页面时立即补取一次。 +- 状态卡把“总流程进度”和“当前操作进度”分开显示;转写的分段百分比、后台 Job 的真实进度和说明不再与固定任务阶段百分比混为一谈。 +- 补齐全自动流程内部小写 `transcribing / ai_analyzing / cutting` 的步骤映射,避免时间线短暂错误退回“任务创建”。 +- 已完成处理但仍有排期/发送任务时,状态概览继续读取每条有效切片的最新发布状态;当前任务 20 显示托管发布 `9/12`、当前发布进度 `75%` 和总流程 `97%`,而不是笼统的“已完成 100%”。失败或需复核会把最后一步显示为警告。 +- AI 专用轮询只更新 AI 区域自身进度,运行日志固定由统一快照刷新;新增请求去重,避免多个轮询响应互相覆盖。 +- 定向回归 `40 passed`、完整回归 `793 passed`;Ruff、Python Compileall、JavaScript 语法和 `git diff --check` 均通过。未调用真实 AI、火山转写、FFmpeg 或抖音/B站发布。 +- 8001 Web 进程已在保留 8765 Windows Worker 的情况下安全重启;`/health`、Scheduler 和 Worker 均健康,活动库 `quick_check=ok`、`foreign_key_check=0`。浏览器实测快照时间从 `14:58:11` 自动更新到 `14:58:14`,桌面与 390px 窄屏状态卡无横向溢出。 + ## 2026-08-26 工作台周统计与发布口径修正 - 工作台顶部改为单一“本周任务概览”面板,核心指标精简为本周新增、已切片、待推送和失败任务;原“待处理、待检查、待加字幕、工作流程、今日处理焦点”已移除。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 3a7353b..0c4191d 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,12 @@ # Next Steps +## 2026-08-26 任务详情实时进度检查 + +1. 打开 `http://127.0.0.1:8001/tasks/3210d91ee1fb`,状态概览应显示“托管发布中 · 9/12”,并分别显示总流程 `97%`、当前操作“托管发布” `75%`;实际数值会随后续排期发送继续变化。 +2. 停留在当前页面即可观察:有运行任务或待执行排期时,状态概览、当前操作和右侧运行日志每 3 秒使用同一份快照更新,不再需要 F5 或切换任务页。 +3. 以后创建低风险手动测试任务时,可观察转写或 AI 的“当前操作”百分比;不要为了验证界面重复运行重要任务,也不要使用会产生额度或平台投稿的真实流程做测试。 +4. 发布失败或结果不确定时会显示黄色警告并保留 `NEED_REVIEW` 人工核对边界;本轮没有触发真实 AI、切片或投稿。 + ## 2026-08-26 工作台改版后检查 1. 继续通过 `http://127.0.0.1:8001/` 使用工作台;顶部应显示本周一至周日范围、四项核心指标和每日新增任务柱状图。 diff --git a/app/services/task_service.py b/app/services/task_service.py index 044231c..8805e82 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -49,6 +49,7 @@ get_source_video_path, resolve_video_file_path, ) +from app.services.publish_domain import TERMINAL_PUBLISH_STATUSES from app.services.task_lifecycle_service import ( TaskStatusConflictError, create_task_record, @@ -289,6 +290,12 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.FAILED_SCHEDULE_CREATING.value: 9, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 10, TaskStatus.pending_review.value: 5, + TaskStatus.pending_processing.value: 2, + TaskStatus.audio_extracting.value: 2, + TaskStatus.transcribing.value: 3, + TaskStatus.pending_ai.value: 4, + TaskStatus.ai_analyzing.value: 4, + TaskStatus.cutting.value: 6, TaskStatus.completed.value: 11, TaskStatus.completed_with_errors.value: 11, TaskStatus.failed.value: 1, @@ -626,6 +633,170 @@ def get_task( return _row_to_task(row, include_video_probe=include_video_probe) if row else None +def _get_active_workflow_job(task_id: str) -> dict: + from app.services import job_service + + with get_connection() as connection: + row = connection.execute( + """ + SELECT id, job_type, status, progress, message, updated_at + FROM workflow_jobs + WHERE task_id = ? AND status IN (?, ?) + ORDER BY + CASE status WHEN ? THEN 0 ELSE 1 END, + updated_at DESC, + created_at DESC, + id DESC + LIMIT 1 + """, + ( + task_id, + job_service.JOB_STATUS_RUNNING, + job_service.JOB_STATUS_QUEUED, + job_service.JOB_STATUS_RUNNING, + ), + ).fetchone() + if not row: + return {} + job = dict(row) + job["job_type_label"] = job_service.JOB_TYPE_LABELS.get(job["job_type"], job["job_type"]) + job["status_label"] = job_service.JOB_STATUS_LABELS.get(job["status"], job["status"]) + return job + + +def _get_publish_live_summary(task_id: str) -> dict: + terminal_statuses = {status.upper() for status in TERMINAL_PUBLISH_STATUSES} + with get_connection() as connection: + rows = connection.execute( + """ + WITH latest_publish_job AS ( + SELECT + publish_jobs.status, + ROW_NUMBER() OVER ( + PARTITION BY publish_jobs.output_clip_id, publish_jobs.platform + ORDER BY publish_jobs.created_at DESC, + publish_jobs.updated_at DESC, + publish_jobs.id DESC + ) AS row_number + FROM publish_jobs + INNER JOIN output_clip + ON output_clip.id = publish_jobs.output_clip_id + AND output_clip.task_id = ? + AND output_clip.status = 'completed' + AND output_clip.is_active = 1 + ) + SELECT UPPER(status) AS status, COUNT(*) AS count + FROM latest_publish_job + WHERE row_number = 1 + GROUP BY UPPER(status) + """, + (task_id,), + ).fetchall() + + statuses = {str(row["status"] or "").upper(): int(row["count"] or 0) for row in rows} + total = sum(statuses.values()) + success = sum(statuses.get(status, 0) for status in {"PUBLISHED", "EXPORTED"}) + cancelled = statuses.get("CANCELLED", 0) + resolved = sum(statuses.get(status, 0) for status in terminal_statuses) + pending = sum(statuses.get(status, 0) for status in {"DRAFT", "WAITING", "SCHEDULED"}) + publishing = statuses.get("PUBLISHING", 0) + failed = statuses.get("FAILED", 0) + need_review = statuses.get("NEED_REVIEW", 0) + attention = failed + need_review + progress = round(success / total * 100) if total else 0 + + if not total: + state = "none" + label = "尚未创建发布任务" + message = "处理完成后可同步到发送中心。" + elif attention: + state = "attention" + label = f"发布需处理 · {attention} 条" + message = f"已成功 {success}/{total} 条,另有 {attention} 条失败或需要人工复核。" + elif publishing: + state = "publishing" + label = f"正在发布 · {success}/{total}" + message = f"平台正在处理 {publishing} 条,已成功 {success}/{total} 条。" + elif pending: + state = "scheduled" + label = f"托管发布中 · {success}/{total}" + message = f"已成功 {success}/{total} 条,另有 {pending} 条正在等待排期发送。" + elif resolved == total and success == total: + state = "completed" + label = f"发布完成 · {success}/{total}" + message = f"全部 {total} 条发布任务均已完成。" + else: + state = "resolved" + label = f"发布已结束 · {success}/{total}" + message = f"已成功 {success}/{total} 条,取消 {cancelled} 条。" + + return { + "state": state, + "label": label, + "message": message, + "progress": progress, + "total": total, + "success": success, + "resolved": resolved, + "pending": pending, + "publishing": publishing, + "failed": failed, + "need_review": need_review, + "cancelled": cancelled, + "should_poll": bool(pending or publishing), + } + + +def _build_live_activity(task: dict, active_job: dict, publish: dict) -> dict: + status = task["status"] + transcript = task.get("transcript_progress") or {} + publish_task_statuses = { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, + TaskStatus.completed_with_errors.value, + } + + if publish["total"] and status in publish_task_statuses: + return { + "kind": "publish", + "label": "托管发布", + "status": publish["state"], + "progress": publish["progress"], + "message": publish["message"], + "updated_at": "", + } + + if status in {TaskStatus.TRANSCRIBING.value, TaskStatus.transcribing.value} and transcript: + return { + "kind": "transcript", + "label": "转写文本", + "status": str(transcript.get("status") or "running"), + "progress": max(0, min(100, int(transcript.get("percent") or 0))), + "message": str(transcript.get("message") or "正在转写文本"), + "updated_at": str(transcript.get("updated_at") or ""), + } + + if active_job: + return { + "kind": str(active_job.get("job_type") or "workflow"), + "label": str(active_job.get("job_type_label") or "后台任务"), + "status": str(active_job.get("status") or "running"), + "progress": max(0, min(100, int(active_job.get("progress") or 0))), + "message": str(active_job.get("message") or active_job.get("status_label") or "正在处理"), + "updated_at": str(active_job.get("updated_at") or ""), + } + + return { + "kind": "task", + "label": task["status_label"], + "status": status, + "progress": max(0, min(100, int(task.get("progress") or 0))), + "message": task.get("error_message") or f"当前阶段:{task['status_label']}", + "updated_at": str(task.get("updated_at_raw") or ""), + } + + def get_task_live_status(task_id: str) -> dict: task = get_task(task_id, include_video_probe=False) if not task: @@ -633,10 +804,40 @@ def get_task_live_status(task_id: str) -> dict: status = task["status"] auto_mode = bool(task.get("auto_mode")) - is_running = auto_mode and status in AUTO_PIPELINE_RUNNING_STATUSES + active_job = _get_active_workflow_job(task_id) + publish = _get_publish_live_summary(task_id) + activity = _build_live_activity(task, active_job, publish) + task_is_running = status in AUTO_PIPELINE_RUNNING_STATUSES + is_running = bool(task_is_running or active_job or publish["should_poll"]) should_poll = is_running candidate_count = count_clip_candidates(task_id) output_clip_count = int(task.get("output_clip_count") or 0) + workflow_steps = get_task_workflow_steps(task) + + publish_task_statuses = { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, + TaskStatus.completed_with_errors.value, + } + display_status_label = task["status_label"] + overall_progress = int(task.get("progress") or 0) + if publish["total"] and status in publish_task_statuses: + display_status_label = publish["label"] + overall_progress = 100 if publish["state"] == "completed" else min( + 99, + 90 + round(publish["progress"] * 0.09), + ) + if workflow_steps: + workflow_steps[-1]["name"] = "平台发布" + if publish["state"] == "attention": + workflow_steps[-1]["state"] = "warning" + elif publish["state"] in {"scheduled", "publishing"}: + workflow_steps[-1]["state"] = "current" + + runtime_status = "running" if should_poll else ("completed" if overall_progress >= 100 else "idle") + if task.get("error_message") or publish["state"] == "attention": + runtime_status = "failed" primary_action = "none" if auto_mode: @@ -657,14 +858,21 @@ def get_task_live_status(task_id: str) -> dict: return { "task_id": task_id, + "snapshot_at": _now_iso(), "status": status, - "status_label": task["status_label"], - "progress": int(task.get("progress") or 0), + "status_label": display_status_label, + "task_status_label": task["status_label"], + "progress": overall_progress, + "task_progress": int(task.get("progress") or 0), "updated_at": task["updated_at"], "error_message": task.get("error_message") or "", "is_running": is_running, "should_poll": should_poll, - "workflow_steps": get_task_workflow_steps(task), + "runtime_status": runtime_status, + "runtime_status_label": display_status_label, + "workflow_steps": workflow_steps, + "active_operation": activity, + "publish": publish, "log_lines": _read_task_log_tail(task_id), "counts": { "candidates": candidate_count, diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 6b97250..d2b68fe 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -645,6 +645,19 @@ tbody tr:last-child td { font-size: 30px; } +.live-progress-summary { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.live-progress-summary span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + .toolbar { flex-wrap: wrap; justify-content: flex-start; @@ -1114,6 +1127,65 @@ fieldset input.visually-hidden-file { min-height: 210px; } +.live-current-operation { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px 14px; + align-items: center; + margin-top: 14px; + padding: 12px 14px; + border: 1px solid rgba(37, 111, 255, 0.14); + border-radius: 8px; + background: rgba(37, 111, 255, 0.06); +} + +.live-current-operation div { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: baseline; + min-width: 0; +} + +.live-current-operation div span { + color: var(--muted); + font-size: 12px; +} + +.live-current-operation > strong { + color: var(--blue); + font-size: 18px; +} + +.live-current-operation p { + grid-column: 1 / -1; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} + +.live-current-operation[data-status="attention"], +.live-current-operation[data-status="failed"] { + border-color: rgba(230, 159, 0, 0.24); + background: rgba(230, 159, 0, 0.08); +} + +.live-current-operation[data-status="attention"] > strong, +.live-current-operation[data-status="failed"] > strong { + color: var(--amber); +} + +[data-task-live-status-pill][data-status="running"] { + background: var(--blue-soft); + color: var(--blue); +} + +[data-task-live-status-pill][data-status="failed"] { + background: var(--amber-soft); + color: var(--amber); +} + .metro-timeline { position: relative; display: grid; @@ -3982,6 +4054,15 @@ body.transcript-drawer-open .main-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .live-current-operation { + grid-template-columns: 1fr; + } + + .live-current-operation > strong, + .live-current-operation p { + grid-column: 1; + } + .segmented, .source-grid, .upload-card, diff --git a/app/static/js/app.js b/app/static/js/app.js index 454b01e..31c09b6 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -119,6 +119,7 @@ async function handleProcessAction(button) { if (result) result.textContent = data.message || "处理完成,正在刷新页面..."; if (button.dataset.endpoint.includes("/process/transcript")) { startTranscriptPolling(true); + startTaskLiveStatusPolling(true); if (data.status === "completed") { window.setTimeout(() => window.location.reload(), 600); } @@ -364,11 +365,16 @@ const taskLiveUpdatedAt = document.querySelector("[data-task-live-updated-at]"); const taskLiveCandidateCount = document.querySelector("[data-task-live-candidate-count]"); const taskLiveOutputCount = document.querySelector("[data-task-live-output-count]"); const taskLiveActions = document.querySelector("[data-live-task-actions]"); +const taskLiveOperation = document.querySelector("[data-task-live-operation]"); +const taskLiveOperationLabel = document.querySelector("[data-task-live-operation-label]"); +const taskLiveOperationProgress = document.querySelector("[data-task-live-operation-progress]"); +const taskLiveOperationMessage = document.querySelector("[data-task-live-operation-message]"); let aiStatusPollingTimer = null; let isAiAnalysisBusy = false; let aiAnalysisControlStates = new Map(); let taskLiveStatusTimer = null; let taskLiveForcedPollingUntil = 0; +let taskLiveStatusRequestInFlight = false; const TASK_LIVE_STATUS_INTERVAL_MS = 3000; function setAiAnalysisControlsDisabled(disabled) { @@ -665,10 +671,7 @@ aiProcessButtons.forEach((button) => { percent: 18, message: "正在保存 Prompt 方案并启动 AI 分析...", }); - renderRuntimeLog({ - status: "running", - log_lines: ["正在启动 AI 分析,请稍等..."], - }); + startTaskLiveStatusPolling(true); try { await saveTaskAiPromptSettings(); @@ -1762,8 +1765,13 @@ function renderRuntimeLog(status) { completed: "已完成", failed: "失败", }; - runtimeLogState.textContent = status.status_label || labelMap[status.status] || status.task_status_label || "已刷新"; - runtimeLogState.dataset.status = status.status || "idle"; + const runtimeStatus = status.runtime_status || status.status || "idle"; + runtimeLogState.textContent = status.runtime_status_label + || labelMap[runtimeStatus] + || status.status_label + || status.task_status_label + || "已刷新"; + runtimeLogState.dataset.status = runtimeStatus; } if (!runtimeLogLines) return; const lines = Array.isArray(status.log_lines) ? status.log_lines : []; @@ -1826,6 +1834,7 @@ function renderTaskLiveStatus(data) { const progress = Math.max(0, Math.min(100, Number(data.progress || 0))); document.querySelectorAll("[data-task-live-status-label]").forEach((node) => { node.textContent = data.status_label || data.status || "状态未知"; + node.dataset.status = data.runtime_status || (data.should_poll ? "running" : "completed"); }); const headerStatus = document.querySelector("[data-task-live-header-status]"); if (headerStatus) headerStatus.textContent = data.status_label || data.status || "状态未知"; @@ -1839,6 +1848,18 @@ function renderTaskLiveStatus(data) { if (taskLiveOutputCount) taskLiveOutputCount.textContent = `${outputCount} 条`; if (aiCandidateCountPill) aiCandidateCountPill.textContent = `${candidateCount} 条候选`; + const operation = data.active_operation || {}; + const operationProgress = Math.max(0, Math.min(100, Number(operation.progress || 0))); + if (taskLiveOperation) taskLiveOperation.dataset.status = operation.status || "idle"; + if (taskLiveOperationLabel) { + taskLiveOperationLabel.textContent = operation.label || data.task_status_label || "当前任务"; + } + if (taskLiveOperationProgress) taskLiveOperationProgress.textContent = `${operationProgress}%`; + if (taskLiveOperationMessage) { + taskLiveOperationMessage.textContent = operation.message + || `当前阶段:${data.task_status_label || data.status_label || "状态未知"}`; + } + const allowedStepStates = new Set(["done", "current", "pending", "warning"]); (Array.isArray(data.workflow_steps) ? data.workflow_steps : []).forEach((step) => { const node = taskLiveOverview?.querySelector(`[data-task-live-step="${step.index}"]`); @@ -1858,6 +1879,7 @@ function renderTaskLiveStatus(data) { autoPipelineMonitor.dataset.running = data.should_poll ? "true" : "false"; } if (taskLiveNote) { + taskLiveNote.dataset.snapshotAt = data.snapshot_at || ""; if (data.error_message) { taskLiveNote.dataset.state = "error"; taskLiveNote.textContent = `流程已暂停:${summarizeErrorMessage(data.error_message)}`; @@ -1881,6 +1903,7 @@ function scheduleTaskLiveStatusPolling() { async function pollTaskLiveStatus() { if (!autoPipelineMonitor) return null; + if (taskLiveStatusRequestInFlight) return null; const taskId = autoPipelineMonitor.dataset.taskId; if (!taskId) return null; if (taskLiveStatusTimer) { @@ -1888,6 +1911,7 @@ async function pollTaskLiveStatus() { taskLiveStatusTimer = null; } + taskLiveStatusRequestInFlight = true; try { const data = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/live-status`); renderTaskLiveStatus(data); @@ -1903,6 +1927,8 @@ async function pollTaskLiveStatus() { } scheduleTaskLiveStatusPolling(); return null; + } finally { + taskLiveStatusRequestInFlight = false; } } @@ -1933,7 +1959,6 @@ async function pollAiAnalysisStatus(keepPolling = false) { throw new Error(data.detail || "读取 AI 分析状态失败"); } renderAiAnalysisProgress(data); - renderRuntimeLog(data); if (aiStatusPollingTimer) { window.clearTimeout(aiStatusPollingTimer); aiStatusPollingTimer = null; diff --git a/app/templates/task_detail.html b/app/templates/task_detail.html index a907cf8..24fc284 100644 --- a/app/templates/task_detail.html +++ b/app/templates/task_detail.html @@ -44,18 +44,17 @@

任务详情 · {{ task.title }}

-{% if task.auto_mode %}
全自动模式已接管这个任务。

系统会自动推进音频提取、转写、AI 选片和视频切割;切片后会生成字幕草稿并暂停。审核烧录可恢复后续流程;跳过字幕则先进入片段审核,保存确认后才同步发送中心。

-{% endif %}
@@ -85,12 +84,23 @@

基础信息

Status

状态概览

- {{ task.status_label }} + {{ task.status_label }}
- {{ task.progress }}% +
+ {{ task.progress }}% + 总流程进度 +
+
+
+ 当前操作 + {{ task.status_label }} +
+ {{ task.progress }}% +

正在读取最新任务进度…

+
    {% for step in workflow_steps %}
  1. @@ -104,7 +114,7 @@

    状态概览

    当前阶段 异常 / 待处理 -

    正在连接任务进度…

    +

    正在连接任务进度…

@@ -284,7 +294,7 @@

已生成视频

Runtime

运行日志

- 待刷新 + 正在连接
任务开始后,这里会自动显示最新运行日志。

diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index e274237..ea822b4 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,14 @@ # UI 参考说明 +## 2026-08-26 更新:任务详情实时进度同步 + +- 状态概览新增“总流程进度”和“当前操作”两层信息:总进度保留全流程位置,当前操作显示转写、AI、切片或托管发布的独立百分比和最新说明。 +- 手动任务与全自动任务共用同一个 `/live-status` 快照;有活动任务或待执行发布排期时每 3 秒局部刷新,页面隐藏时暂停,回到前台立即读取最新状态。 +- 运行日志、状态标签、进度条、地铁步骤、候选数和输出数来自同一次快照;AI 区域的独立轮询不再单独覆盖右侧日志。 +- 全自动流程兼容内部的小写运行状态,转写、AI 和切片分别稳定定位在第 3、4、6 步,不会短暂退回第 1 步。 +- 已同步发送中心的任务会继续显示发布汇总:等待/排期/发送中保持蓝色当前阶段,失败或需复核显示黄色警告,全部发布成功后才显示发布完成。手动和全自动任务遵循相同口径。 +- 小屏下“当前操作”卡改为单列,百分比、说明和时间线保持可读,不引入新的前端框架。 + ## 2026-08-26 更新:工作台周统计总览 - 工作台顶部由分散的统计卡、工作流程和今日焦点合并为一个“本周任务概览”面板,保留清晰留白、浅色玻璃背景和蓝色主强调。 diff --git a/docs/agent_tasks/2026-08-26-task-progress-live-sync.md b/docs/agent_tasks/2026-08-26-task-progress-live-sync.md new file mode 100644 index 0000000..66ae16c --- /dev/null +++ b/docs/agent_tasks/2026-08-26-task-progress-live-sync.md @@ -0,0 +1,60 @@ +# 任务详情实时进度与日志同步 + +## 背景 + +任务详情页虽然已有 `/api/tasks/{task_id}/live-status`,但前端只在全自动任务处于少数运行状态时持续轮询。手动转写、手动 AI、手动切片,以及主任务已完成但发布任务仍在排期或发送的场景,会很快停止刷新。运行日志还会被 AI 专用接口单独更新,导致日志和状态概览来自不同时间点、不同状态口径。 + +## 目标 + +- 状态概览与运行日志统一由同一份任务实时快照驱动。 +- 全自动和手动任务都能在当前页面持续看到最新阶段、总流程进度和当前操作进度。 +- 已进入发送中心的任务显示真实发布汇总,不再把仍有排期任务的流程笼统显示为 100% 已完成。 +- 页面无需整页刷新;切回浏览器标签页时立即补一次最新快照。 + +## 允许修改范围 + +- `app/services/task_service.py` +- `app/templates/task_detail.html` +- `app/static/js/app.js` +- `app/static/css/styles.css` +- 任务实时状态相关测试文件。 +- `DEVELOPMENT_LOG.md`、`NEXT_STEPS.md`、`docs/UI_REFERENCE.md`。 + +## 禁止修改范围 + +- SQLite 表结构、迁移和现有真实任务数据。 +- AI、转写、切片和发布状态机的写入逻辑。 +- Provider、账号、Cookie、Token、`.env` 和认证配置。 +- 真实 AI 调用、真实切片、真实抖音 / B站投稿、部署和 PR 合并。 + +## 已确定实现要求 + +- `/live-status` 一次返回主任务状态、活动 Workflow Job、转写细分进度、最新运行日志和发布任务汇总。 +- 主进度必须明确标为“总流程进度”;另行显示当前操作名称、操作百分比和最新说明,避免把步骤进度误认为总流程进度。 +- 手动任务不能再被 `auto_mode` 条件排除;有活动任务或待执行发布排期时,页面可见期间每 3 秒刷新一次,页面隐藏时暂停,回到前台立即刷新。 +- 运行日志只由 `/live-status` 快照更新,AI 专用轮询只负责 AI 区域自身进度。 +- 发布汇总以每个切片最新的 `publish_jobs` 记录为准;区分已发布/已导出、排期/等待/发送中、失败/需复核和取消。 +- 有发布任务尚未完成时,状态概览显示托管发布进度和 `已完成/总数`,最后一个时间线节点保持当前或警告状态;全部成功后才显示发布完成。 +- 所有新增接口逻辑保持只读,不修改真实任务、作业或发布记录。 + +## 验收标准 + +- 全自动和手动任务在运行或等待托管发布期间都持续发起 3 秒一次的局部状态刷新,不依赖 F5 或切换页面。 +- 状态概览与运行日志使用同一次响应中的数据,页面不存在第二条日志刷新数据源。 +- 转写或后台作业运行时显示当前操作百分比和说明;总流程百分比与当前操作百分比有清晰标签。 +- 存在 9 条已发布、3 条待排期的任务显示“9/12”托管发布进度,而不是笼统的“已完成 100%”。 +- 发布失败或需复核时使用警告语义,不冒充发布成功。 +- 既有全自动状态、失败重试和页面局部刷新测试继续通过。 + +## 测试命令 + +- `.venv\Scripts\python.exe -m pytest tests/test_auto_pipeline.py tests/test_pipeline_state_stability.py -q` +- `.venv\Scripts\python.exe -m pytest -q` +- `.venv\Scripts\python.exe -m compileall -q app tests` +- `node --check app/static/js/app.js` +- `git diff --check` +- 浏览器刷新 `http://127.0.0.1:8001/tasks/3210d91ee1fb`,核对发布汇总、轮询更新时间、日志同步、桌面与窄屏布局和控制台。 + +## 返回格式 + +报告修改文件、实时状态口径、测试结果、浏览器验收证据、分支、提交、推送和 PR;不得把只读页面验证描述为真实 AI、切片或平台发布验证。 diff --git a/tests/test_auto_pipeline.py b/tests/test_auto_pipeline.py index d135ada..6a63aa8 100644 --- a/tests/test_auto_pipeline.py +++ b/tests/test_auto_pipeline.py @@ -720,6 +720,100 @@ def test_live_status_endpoint_returns_completed_actions(monkeypatch): assert all(step["state"] == "done" for step in payload["workflow_steps"]) +def test_live_status_endpoint_tracks_manual_workflow_job(): + task_id = "test-auto-manual-live-job" + create_task_record( + TaskCreate(task_name="手动任务实时进度", selection_profile="general"), + task_id=task_id, + ) + update_task_status(task_id, TaskStatus.ai_analyzing) + job = job_service.create_job(task_id, job_service.JOB_TYPE_AI_ANALYSIS) + claimed = job_service.claim_job(job["id"], "live-status-test-worker") + job_service.update_job_progress( + job["id"], + 61, + "正在分析第 14/23 段", + lease_owner="live-status-test-worker", + lease_token=claimed["lease_token"], + ) + + with TestClient(app) as client: + response = client.get(f"/api/tasks/{task_id}/live-status", headers=_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["should_poll"] is True + assert payload["active_operation"]["kind"] == job_service.JOB_TYPE_AI_ANALYSIS + assert payload["active_operation"]["progress"] == 61 + assert payload["active_operation"]["message"] == "正在分析第 14/23 段" + assert payload["workflow_steps"][3]["state"] == "current" + + +def test_live_status_endpoint_maps_lowercase_status_inside_auto_pipeline(): + task = _create_auto_task("test-auto-live-lowercase") + update_task_status(task["id"], TaskStatus.transcribing) + + with TestClient(app) as client: + response = client.get(f"/api/tasks/{task['id']}/live-status", headers=_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["should_poll"] is True + assert payload["workflow_steps"][2]["state"] == "current" + assert payload["workflow_steps"][0]["state"] == "done" + + +def test_live_status_endpoint_combines_publish_progress_with_task_timeline(): + task = _create_auto_task("test-auto-live-publish") + update_task_status(task["id"], TaskStatus.COMPLETED) + now = datetime.now(timezone.utc).isoformat() + statuses = ("PUBLISHED", "SCHEDULED", "NEED_REVIEW") + with get_connection() as connection: + for index, status in enumerate(statuses, start=1): + output_id = f"{task['id']}-output-{index}" + connection.execute( + """ + INSERT INTO output_clip ( + id, task_id, output_file_path, output_file_name, + status, is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'completed', 1, ?, ?) + """, + (output_id, task["id"], f"clip-{index}.mp4", f"clip-{index}.mp4", now, now), + ) + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, platform, publish_mode, + status, created_at, updated_at + ) VALUES (?, ?, ?, 'douyin', 'local_browser', ?, ?, ?) + """, + (f"{task['id']}-publish-{index}", task["id"], output_id, status, now, now), + ) + connection.commit() + + with TestClient(app) as client: + response = client.get(f"/api/tasks/{task['id']}/live-status", headers=_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == TaskStatus.COMPLETED.value + assert payload["task_progress"] == 100 + assert payload["progress"] == 93 + assert payload["status_label"] == "发布需处理 · 1 条" + assert payload["publish"]["total"] == 3 + assert payload["publish"]["success"] == 1 + assert payload["publish"]["pending"] == 1 + assert payload["publish"]["need_review"] == 1 + assert payload["active_operation"]["kind"] == "publish" + assert payload["active_operation"]["progress"] == 33 + assert payload["workflow_steps"][-1] == { + "name": "平台发布", + "index": "11", + "state": "warning", + } + assert payload["should_poll"] is True + + def test_live_status_endpoint_returns_404_for_missing_task(): with TestClient(app) as client: response = client.get("/api/tasks/test-auto-missing/live-status", headers=_headers()) @@ -737,8 +831,17 @@ def test_task_detail_live_status_frontend_uses_partial_refresh(): ] assert "data-task-live-overview" in template + assert "data-task-live-operation" in template assert "data-live-task-actions" in template assert "/live-status" in live_script assert "TASK_LIVE_STATUS_INTERVAL_MS = 3000" in script assert 'document.addEventListener("visibilitychange"' in live_script + assert "taskLiveStatusRequestInFlight" in live_script + assert "renderRuntimeLog(data);" in live_script assert "window.location.reload" not in live_script + + ai_poll_script = script[ + script.index("async function pollAiAnalysisStatus"): + script.index("function stopAiAnalysisStatusPolling") + ] + assert "renderRuntimeLog" not in ai_poll_script