Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -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 工作台周统计与发布口径修正

- 工作台顶部改为单一“本周任务概览”面板,核心指标精简为本周新增、已切片、待推送和失败任务;原“待处理、待检查、待加字幕、工作流程、今日处理焦点”已移除。
Expand Down
7 changes: 7 additions & 0 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -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/` 使用工作台;顶部应显示本周一至周日范围、四项核心指标和每日新增任务柱状图。
Expand Down
216 changes: 212 additions & 4 deletions app/services/task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -626,17 +633,211 @@ 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:
raise ValueError("任务不存在")

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:
Expand All @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions app/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading