diff --git a/.env.example b/.env.example index cd2c69c..30ce131 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ FFMPEG_TIMEOUT=600 # 如果以后要换存储盘,可以同步修改 docker-compose.yml 里的 volumes 和这里的路径。 STORAGE_ROOT=E:\直播间切片工作流存储 TASKS_DIR=E:\直播间切片工作流存储 +# 浏览器上传超过 1MB 时使用的临时目录;必须放在大容量存储盘。 +UPLOAD_TEMP_DIR=E:\直播间切片工作流存储\_临时上传 AI_DEFAULT_PROVIDER=remote AI_REQUEST_TIMEOUT_SECONDS=120 @@ -61,14 +63,14 @@ AI_LOCAL_HEALTH_TIMEOUT_SECONDS=30 OPENCLI_LOCAL_BASE_URL=http://127.0.0.1:8001 OPENCLI_HOST_BRIDGE_URL=http://host.docker.internal:8765 -# v1.5.0 统一排期与 Windows Chrome 发布 Worker +# v2.0.0 统一排期与 Windows Chrome 发布 Worker APP_TIMEZONE=Asia/Shanghai PUBLISH_SCHEDULER_ENABLED=true PUBLISH_SCHEDULER_INTERVAL_SECONDS=5 PUBLISH_DEFAULT_MODE=local_browser PUBLISH_JOB_STALE_MINUTES=30 PUBLISH_SCHEDULER_MAX_RETRY_COUNT=3 -PUBLISH_SCHEDULER_EXPORT_DIR= +PUBLISH_SCHEDULER_EXPORT_DIR=E:\直播间切片工作流存储\_发布包 PUBLISH_SCHEDULER_ALLOW_PUBLISH_WITHOUT_REVIEW=false PUBLISH_ENABLE_OPENCLI_FALLBACK=false # 请使用随机长字符串;start_publish_worker.ps1 会在本地 .env 缺失时自动生成。 diff --git a/AGENTS.md b/AGENTS.md index 426a95d..08b944b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ - 项目英文名:NiuMa Studio - 项目中文说明:牛马片场,本地 AI 高光生产后台 - 项目目录:`C:\Users\10578\Documents\New project 2` -- 当前版本:1.5.0 -- 当前状态:Windows 本地后台处理链路与抖音/B站统一真实发布架构已实现;真实投稿仍遵守账号登录、平台验证、风控和人工确认边界 +- 当前版本:2.0.0 +- 当前状态:Windows 本地 AI 高光生产闭环已完成整合,素材、转写、AI 选片、审核、切片、内容准备、排期与抖音/B站统一真实发布链路已打通;真实投稿仍遵守账号登录、平台验证、风控和人工确认边界 ## 协作方式 @@ -27,7 +27,7 @@ - AI 分析:预留 OpenAI-compatible API 或本地大模型接口 - 语音转写:预留服务接口 -## 当前 1.5.0 范围 +## 当前 2.0.0 范围 - 新建视频处理任务,支持直播录像、综艺访谈和其他长视频素材。 - 上传本地视频或选择 NAS / 本地目录中的已有视频。 @@ -37,12 +37,14 @@ - 生成逐句时间戳原文,供 AI 分析、字幕和片段审核复用。 - AI 分析候选短视频片段,支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama。 - 后台查看并编辑候选片段。 -- 自动切割视频并输出短视频文件。 +- 保存审核选择后按需自动生成最新切片,并安全同步到发送中心。 +- 自动切割视频并输出短视频文件;全自动任务详情每 3 秒局部更新状态、进度、日志和可用操作。 - 保留字幕工作台,但全自动流程继续跳过字幕生成、烧录和叠加。 -- 发送中心分为内容准备、排期计划和执行记录,生成标题、简介、话题和候选封面帧。 +- 发送中心分为内容准备、排期计划和执行记录,生成标题、简介、话题和候选封面帧;支持任务分组、已排期标记、月历日期详情、跨午夜窗口和续接当前平台最晚排期。 - 立即发送与定时发送统一使用 SQLite Scheduler、Publisher Registry 和 Windows Chrome Worker。 - 抖音与 B站使用各自 Publisher;`manual_export` 仅显式导出本地发布包,`opencli` 仅作默认关闭的兼容模式。 -- 展示任务状态、处理进度和异常信息。 +- 统一使用 E 盘任务存储、临时上传目录和发布包目录;永久删除只清理项目托管产物,不删除外部唯一原片。 +- 展示任务状态、处理进度和异常信息,失败步骤可重试,发布结果不确定时进入人工复核。 ## 当前不做 diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 229053e..94e63d3 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,49 @@ # Development Log +## 2026-08-03 v2.0.0 主分支收拢与文档定版 + +- 将当前工作分支相对 `master` 的素材存储、自动流水线、审核切片、发送中心、排期和真实发布成果统一作为 v2.0 本地生产闭环,不改变 FastAPI + SQLite + Windows Worker 的现有架构。 +- 项目版本、FastAPI API、Windows 发布 Worker 和界面侧栏统一更新为 `2.0.0` / `v2.0`;全自动流程结束提示同步改为当前发送中心与排期语义。 +- `README.md` 增加项目现状表、完整主流程、功能清单、运行形态和人工边界;明确“发布架构已实现”不等于“所有账号和平台页面已完成无人值守验证”。 +- 新手指南、架构、状态流、部署和路线图同步说明 E 盘存储、两类 AI 选片、任务实时进度、片段审核同步、排期月历、Windows Chrome Worker、发布终态与后续真实灰度重点。 +- 保留所有历史版本开发记录和既有验收说明;v2.0 后续仍优先做抖音 / B站单条真实灰度、长时间运行和数据备份验收,不扩大到多用户、云端 SaaS 或绕过平台验证。 +- 发布前验证已完成:Python 编译、Ruff、`app.js` / `publish-center.js` 语法和 Git 空白检查全部通过,完整自动化测试 `407 passed`;仅保留 9 条既有依赖弃用提醒,测试未连接真实平台或触发投稿。 + +## 2026-08-03 发送中心排期日历展开与时间升序 + +- “排期计划”月历中的有任务日期现在可点击或使用键盘 Enter / 空格打开,在月历下方完整展示当天当前平台的全部排期,并明确显示日期、平台和任务数量。 +- 当天详情按北京时间从早到晚展示时间、标题、账号与状态;点击任意一条只会滚动并高亮下方原任务,不复制操作按钮、不修改排期,也不触发投稿。 +- 月历前两条预览与下方任务清单统一按 `scheduled_at` 升序;已排期任务在前,未排期任务在后且保持原有内部顺序。应用、调整、清除或取消排期后会原地重排,无需刷新页面。 +- 切换平台或月份会关闭旧日期详情;选中日期已无排期时也会自动关闭。抖音和 B站继续严格隔离,日期仍按 `Asia/Shanghai` 北京时间计算。 +- 未新增 API、数据库字段或迁移,未修改调度和真实发布边界;新增浏览器回归覆盖完整日期展开、稳定排序、键盘访问、定位高亮、动态取消排期和窄屏无横向溢出。 +- 本机真实页面已只读验收:8 月 3 日完整显示 `08:00 → 11:00 → 14:00 → 17:00 → 20:00 → 23:00`,主清单继续衔接到 8 月 4 日;控制台无错误,未修改排期或触发投稿。专项测试 `37 passed`、完整测试 `407 passed`,Ruff、JavaScript 语法和 Git 空白检查均通过,仅保留 9 条现有依赖弃用警告。 + +## 2026-08-02 发送中心续接最晚排期 + +- “设置排期”抽屉新增“接在当前平台最晚排期后”:每次点击都实时读取 SQLite,不依赖页面中最多 200 条的缓存列表;抖音和 B站分别计算。 +- 新增 `POST /api/publish/schedules/next-start`。接口只查当前平台仍有效且带未来时间的 `WAITING / SCHEDULED / PUBLISHING` 记录,并排除本次正在调整的已选任务;已发布、失败、取消和另一平台记录不会占用本平台时间线。 +- 续排严格使用当前间隔,并复用跨午夜时段规则:19:00 加 3 小时为 22:00,21:00 加 3 小时为次日 00:00,22:00 加 3 小时超出窗口后顺延到次日 07:00。 +- 发送中心和全自动任务的新默认每日窗口统一为 `07:00 → 00:00`,仍可逐次修改;已有任务配置、已有排期和数据库结构均未改变。 +- 修复全自动 `daily_window` 对跨午夜窗口的旧循环判断,改为复用统一的允许时段计算,不会在 `07:00 → 00:00` 下反复滚动日期。 +- 新增服务层、API、默认值、平台隔离、选中任务排除、无排期、跨午夜、自动流程及浏览器回填测试;相关测试共 `49 passed`,完整测试 `397 passed`,Ruff、Python/JavaScript 语法和 Git 空白检查均通过,测试未触发真实投稿。 + +## 2026-08-02 片段审核同步发送中心修复 + +- 根因确认:片段审核页勾选“启用”后,旧“同步发送中心”只读取已经完成的激活切片;当候选片段已启用但尚未出现在当前切片批次中时,请求会返回“新增 0 条”,发送中心仍只能看到旧版本。 +- “生成切片”现在会先保存页面里的启用状态、标题、摘要和出入点,再开始切割,避免页面选择尚未落库时继续使用旧审核结果。 +- 片段审核页新增“保存并同步发送中心”完整流程:先保存审核选择,再核对当前启用候选与激活成片;选择或内容有变化、文件缺失、成片数量不一致时自动生成安全新版本并同步,完全一致时只做幂等同步,不重复切片。 +- 同步成功后自动进入 `/publish?task_id=...&tab=content`,展开并定位当前原始处理任务;旧版待准备/已排期记录仍按现有版本规则安全转入历史,不删除视频、文案或执行证据。 +- 真实任务 `6753ce9f3bfd` 已完成页面回归:3 条候选均启用、修复前只有 2 条成片和 2 条发送内容;修复后新增 3 条最新发送内容、2 条旧版安全转入历史、失败 0 条,目标“大田怕鸟怕到连杂志图都怕,小S补刀跳海”已出现在抖音内容准备列表,未排期且未触发真实投稿。 +- 新增审核同步服务和路由回归测试。完整自动化测试 `383 passed`,Ruff 与 JavaScript 语法检查通过;仅保留 8 条现有依赖弃用警告。 + +## 2026-08-02 内容准备已排期标记优化 + +- 发送中心“内容准备”中的 `SCHEDULED` 卡片新增蓝色“已排期 + 北京时间”标签,并使用轻蓝渐变底色、蓝色边框、左侧强调线和轻阴影,让已经安排过的任务无需切换页签即可辨认。 +- 已排期卡片底部操作由“加入发布计划”改为“调整排期”;未排期卡片继续保持原白色样式与原按钮文案。 +- 标记直接复用发布任务现有状态和排期时间,不新增数据库字段,不修改历史数据;新增排期、清除排期或取消发送后,标签、卡片颜色和按钮文案会在当前页面原地同步。 +- 新增模板首屏渲染与前端原地更新回归测试。完整测试 `383 passed`,Ruff、JavaScript 语法、Git 空白检查和浏览器控制台错误检查均通过;仅有 8 条现有依赖弃用提醒。 +- 本机 `127.0.0.1:8001/publish` 已按约 1500×1100 桌面视口验收:“姜母鸭汤酒味重到小S喝懵了”显示“已排期 2026-08-02 13:00”,普通未排期卡片保持原样;验收未修改排期、账号或平台数据,也未触发真实投稿。 + ## 2026-08-02 发送中心封面计数平台隔离修复 - 根因确认:发送中心当前停留在抖音时,封面按钮仍统计了页面中隐藏的 B站任务;现场数据库中的 8 条空封面路径全部属于 B站,抖音当前缺失数实际为 0。 @@ -902,3 +946,21 @@ - “移出内容准备”仍是用户主动隐藏记录,“跳过任务”仍是终止当前任务;二者继续使用 `CANCELLED`,不会被普通取消逻辑误恢复。 - 兼容历史数据:数据库初始化时,只把旧版错误标记为“用户取消任务”的最后一条记录安全恢复到 `WAITING`;若同一切片和平台已经有活跃替代任务,则不会制造重复任务。 - 新增状态机、历史数据恢复和浏览器交互回归测试;专项测试 `19 passed`、完整测试 `336 passed`,Python 编译、两个 JavaScript 文件语法检查、Ruff 和差异检查均通过。 + +## 2026-08-02 E 盘统一视频存储与任务永久删除 + +- 新增 `UPLOAD_TEMP_DIR`,并在应用启动时把当前进程的 `TEMP`、`TMP` 和 Python 临时目录指向 E 盘;大于 1 MB 的浏览器上传不再先写入 C 盘系统临时目录。 +- 手动发布包默认目录从项目 `outputs/publish_packages` 调整到 `E:\直播间切片工作流存储\_发布包`;任务原片、音频、切片、字幕和封面继续统一使用 `TASKS_DIR`。 +- 任务列表“移入回收站”改为“永久删除”:只删除系统托管目录,外部 NAS / E 盘原片保留,数据库历史隐藏保留;运行中的处理和发布任务禁止删除。 +- 新增 `scripts/purge_deleted_task_media.py`,默认只预览;`--apply` 会先创建 SQLite 元数据备份,再清理已隐藏任务的 E 盘目录、发布包和精确匹配的旧版 C 盘任务目录。 +- 新增大文件 multipart 临时目录、失败上传回滚、外部原片保护、路径越界、运行中拦截、删除失败回滚、幂等删除和旧任务清理测试。 +- 已对真实数据先预演再执行清理:15 条已隐藏任务共删除 16 个托管目录,释放 `6,213,311,934` 字节(约 6.21 GB);4 条有效任务目录清理前后均完整,外部测试原片保留,清理后再次预演为 0 个残留目录。 +- 清理前 SQLite 元数据备份完整性检查为 `ok`;最终完整测试 `395 passed`,Python 编译、Ruff、JavaScript 语法和差异检查均通过。 + +## 2026-08-02 任务详情进度局部自动刷新 + +- 新增 `GET /api/tasks/{task_id}/live-status` 轻量接口,统一返回任务状态、进度、10 步时间线、日志、候选/输出数量和可用操作,不执行 FFprobe。 +- 全自动任务详情页打开后立即读取一次状态,处理中每 3 秒局部更新状态卡、顶部状态、基础计数和右侧运行日志;页面切回前台时会立即补一次更新。 +- 完成或失败后停止轮询,并原地显示发送中心、片段检查、重试或继续入口;重试/继续全自动流程也不再触发整页刷新。 +- 网络短暂异常时保留当前页面数据并继续重试,不会清空用户正在编辑的 AI Prompt 或改变滚动位置。 +- 新增处理中、失败、完成、404 和前端无整页刷新测试;专项测试 `21 passed`、完整测试 `407 passed`,JavaScript 与 Python 语法检查通过。浏览器实测从 AI 分析中切换到失败状态时,状态卡、日志和重试按钮在 3 秒内更新,未保存输入保持不变。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 179255f..337d9f5 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,48 @@ # Next Steps +## v2.0.0 发布后优先验收 + +1. 打开 Docker Desktop 并运行 `niuma-studio`,浏览器访问 `http://127.0.0.1:8001`;确认左侧显示“v2.0 本地高光生产版”,系统状态与发送中心显示 Windows Worker 正常。 +2. 用一条短测试视频开启全自动任务,确认任务详情的 10 步进度、日志、候选数和输出数每 3 秒局部更新;流程完成后能进入片段审核和发送中心。 +3. 在片段审核修改一条候选的启用状态或时间,点击“保存并同步发送中心”;确认必要时生成新切片,发送中心只使用当前激活版本,旧执行证据仍留在历史。 +4. 在内容准备核对抖音 / B站的视频、标题、简介、话题、封面、账号和可见范围;设置一个跨午夜测试排期,确认月历、日期详情和任务清单按北京时间升序。 +5. 抖音和 B站分别只选一条低风险测试内容做真实灰度;必须在 Chrome 独立窗口人工完成登录与平台验证。遇到验证码、风控或结果不确定时先核对创作者中心,不要直接重试。 +6. 连续运行一段时间后检查 Scheduler、Docker Watcher、Windows Worker 和发布事件日志,并验证 SQLite 备份与 E 盘剩余空间。 + +## 2026-08-03 排期日历展开与排序检查方法 + +1. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新,然后进入“排期计划”。 +2. 同一天有 3 条以上排期时,月历仍会预览前两条并显示“另有 N 条”;点击日期格后,月历下方应展开当天全部排期,时间从早到晚排列。 +3. 当天详情会显示任务标题、账号和状态;点击某一条只会滚动并蓝色高亮下方原任务,不会改变排期或立即发送。 +4. 下方任务清单应先列出全部已排期任务,并按北京时间从最早到最晚排列;未排期任务统一放在已排期任务后面。 +5. 调整、清除或取消排期后,月历、当天详情和主清单应立即更新。切换抖音/B站或月份时,之前展开的日期会关闭,两个平台不会混排。 +6. 日期格也可以按 `Tab` 聚焦后使用 Enter 或空格打开。验证本功能不需要点击“立即发送”,请勿改动即将真实投稿的排期。 + +## 2026-08-02 续接最晚排期使用与检查 + +1. 打开 `http://127.0.0.1:8001/publish` 并按 `Ctrl + F5`,进入“内容准备”或“排期计划”,勾选同一平台需要安排的内容后点击“设置排期”。 +2. 抽屉中的每日开始、结束时间应默认显示 `07:00` 和 `00:00`;这是新默认值,不会改动以前已经保存的排期,也可以为本次排期手动修改。 +3. 先选择发布间隔,再点击“接在当前平台最晚排期后”。页面应显示“当前最晚”和“本次第 1 条”时间;例如当前最晚 19:00、间隔 3 小时,本次应为 22:00。 +4. 如果加上间隔后落在 00:00 至 07:00 的不可发布区间,系统会自动顺延到 07:00;正好得到 00:00 时仍允许排期。 +5. 当前平台没有其他未来排期时,页面会提示手动选择,不会覆盖第 1 条时间。调整时间、间隔或每日窗口后,必须重新点击“预览排期”再确认应用。 +6. 抖音和 B站分别续排;切换平台后需重新勾选。验证此功能只需要预览,不必点击立即发送,也不会绕过账号登录、验证码或平台风控。 + +## 2026-08-02 片段审核同步发送中心检查方法 + +1. 打开目标任务的“片段审核”,勾选想发布的候选片段;无需再依次点击“保存修改”“生成切片”和旧同步按钮,直接点击“保存并同步发送中心”。 +2. 页面会先保存当前勾选、标题、摘要和出入点;如果启用选择与最新成片不一致,会显示“正在保存并生成”,完成后自动打开发送中心并展开当前任务。 +3. 当前真实任务 `6753ce9f3bfd` 已同步成功,发送中心抖音“内容准备”应显示 3 条:大田怕鸟、渣渣爷爷名字、Circus 跳舞;每条均为待准备状态,可以分别点击“加入发布计划”。 +4. 如果当前审核内容和成片完全一致,重复点击只会执行幂等同步,不会重复生成;如果修改了启用状态、标题、摘要、出入点,或成片文件缺失,系统才会创建安全的新切片版本。 +5. 新同步内容不会自动排期或投稿。加入发布计划前仍需逐条核对账号、标题、简介、话题、封面、可见范围和具体北京时间;二维码、验证码、滑块与平台风控继续由人工处理。 + +## 2026-08-02 内容准备已排期标记检查方法 + +1. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新,然后停留在“内容准备”。 +2. 已排期卡片右上角应显示蓝色“已排期 + 具体北京时间”,整张卡片同时出现浅蓝底、蓝色边框和左侧强调线,底部按钮显示“调整排期”。 +3. 未排期卡片不显示该标签,仍保持普通白色卡片和“加入发布计划”按钮;这两种状态应能快速区分。 +4. 新增、调整或清除排期后,卡片标记应在当前页面直接更新,不需要手动刷新。验证时只操作测试任务,不要改动即将真实发送的排期。 +5. 本次只优化状态提示,不改变调度器、账号、素材或真实投稿流程;真实投稿前仍需逐条核对账号、文案、封面、可见范围和排期。 + ## 2026-08-02 发送中心封面计数修复后的检查方法 1. 打开 `http://127.0.0.1:8001/publish` 并刷新页面;当前抖音没有缺失封面时,应显示禁用的“补充抖音缺失封面”,不再出现错误的“(8)”。 @@ -157,7 +200,7 @@ 1. 在项目目录启动后台:`.\.venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8001`。 2. 打开 `http://127.0.0.1:8001/tasks/new`,新建任务并勾选“新建后自动跑完整流水线”。 3. 等任务自动完成准备视频、转写/读取文本、AI 分析、自动选片、原片切割、生成标题文案和创建待发送任务。 -4. 该段是 v1.4.0 历史测试记录。当时默认 `manual_export`;v1.5.0 当前默认 `local_browser`,到点后会调用 Windows Worker 真实投稿。手动扫描命令仍是 `.\.venv\Scripts\python.exe -m app.publish_scheduler run-once`。 +4. 该段是 v1.4.0 历史测试记录。当时默认 `manual_export`;v2.0.0 当前默认 `local_browser`,到点后会调用 Windows Worker 真实投稿。手动扫描命令仍是 `.\.venv\Scripts\python.exe -m app.publish_scheduler run-once`。 5. 发布包默认在 `outputs/publish_packages/{task_id}/{clip_id}/`,应能看到 `clip.mp4`、`title.txt`、`caption.txt`、`hashtags.txt`、`cover_text.txt`、`publish_plan.json`、`metadata.json`。 6. 打开 `/publish`,在发布记录里查看 `SCHEDULED`、`PUBLISHING`、`PUBLISHED`、`FAILED`、`NEED_REVIEW` 等状态;也可以访问 `/api/publish/queue/snapshot` 查看队列快照。 7. 失败任务可以调用 `POST /api/publish/jobs/{job_id}/retry` 重试;立即发布可以调用 `POST /api/publish/jobs/{job_id}/publish-now`;取消和跳过分别调用 `/cancel`、`/skip`。 @@ -847,3 +890,21 @@ 4. 之前由旧版“取消任务”产生、错误信息为“用户取消任务”的记录,会在数据库初始化时自动恢复;如果同一切片和平台已经存在新的活跃任务,则保留新的任务且不重复恢复。 5. 继续确认“移出内容准备”仍会隐藏任务,并可在执行记录中恢复;“跳过任务”仍保持终止状态,不会自动回到准备区。 6. 本次验收不要点击“立即发送”,不会触发抖音或 B站真实投稿。 + +## 2026-08-02 E 盘存储与永久删除验收 + +1. 重启本地后台,打开“系统状态”,确认“视频临时与导出目录”显示“E 盘就绪”,路径分别是 `_临时上传` 和 `_发布包`。 +2. 新建一个测试任务并上传视频,确认任务原片和后续切片只出现在 `E:\直播间切片工作流存储\任务名` 下;C 盘项目目录不应出现新的生产视频副本。 +3. 在任务列表点击“永久删除”,确认提示明确说明无法恢复且外部原片保留;删除后对应 E 盘任务目录和发布包应消失。 +4. 对 NAS 或 E 盘其他目录的引用任务执行删除时,只删除任务生成物,外部唯一原片必须仍然存在。 +5. 转写、切片或真实发布进行中时,删除应被拒绝并显示原因;等待任务结束后再删除。 +6. `scripts/purge_deleted_task_media.py` 默认只输出清单,只有显式带 `--apply` 才会永久清理,并在 `data/backups` 留下 SQLite 元数据备份。 + +## 2026-08-02 任务进度自动刷新验收 + +1. 重启本地后台,打开“新建任务”,保持“全自动流程”开启并创建任务。 +2. 创建成功后停留在任务详情页,不需要按 `F5`;“状态概览”和右侧“运行日志”应每 3 秒自动更新。 +3. 观察蓝色当前节点依次向后移动,已经完成的节点变绿;如果某一步失败,对应节点变黄,并自动出现“重试全自动流程”。 +4. 流程完成后,进度应显示 100%,顶部自动出现“前往发送中心”,有候选片段时同时出现“检查候选片段”。 +5. 可以在 AI Prompt 文本框临时输入几个字后继续等待,确认状态更新时文字没有消失、页面也没有跳回顶部。 +6. 如果网络短暂中断,状态卡会显示“自动更新暂时中断,正在重试”;恢复后不需要手动刷新。 diff --git a/README.md b/README.md index 312434b..9635b9d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,51 @@ # 牛马片场 / NiuMa Studio -牛马片场是一个运行在 Windows 本地的 AI 高光生产后台,用来把直播录像、综艺访谈、长视频素材整理成可转写、可分析、可审核、可切割、可加字幕、可进入发送中心的短视频生产任务。 +牛马片场是一个运行在 Windows 本地的单用户 AI 高光生产后台,用来把直播录像、综艺访谈和其他长视频整理成可审核、可排期、可发送的短视频内容。 -当前版本:`1.5.0`。 +当前版本:`2.0.0`(界面简称 `v2.0`)。 -v1.5.0 将抖音和 B站的立即发送、定时发送统一到 `PublishScheduler → Registry → LocalBrowserPublisher → Windows Worker → 平台 Publisher`。FastAPI 或 Docker 负责排期,Windows Worker 使用系统 Chrome 的独立账号目录执行真实投稿。只有读取到平台作品 ID、稿件 ID 或明确成功链接才进入 `PUBLISHED`;登录失效、验证码、风控和结果不确定进入 `NEED_REVIEW`,不会自动重复上传。 +v2.0 把此前分散在多个开发分支里的素材管理、全自动处理、审核切片、内容准备、排期和真实发布能力收拢为一条稳定主线。FastAPI / Docker 负责业务与排期,Windows Worker 使用系统 Chrome 的独立账号目录执行抖音或 B站投稿。只有取得平台作品 ID、稿件 ID、作品链接或其他明确成功证据才进入 `PUBLISHED`;登录失效、验证码、风控和结果不确定统一进入 `NEED_REVIEW`,不会自动重复上传。 -## 定时发送快速说明 +## v2.0 项目现状 + +| 范围 | 当前状态 | 说明 | +| --- | --- | --- | +| 本地生产闭环 | 已实现 | 素材入库、转写、AI 选片、人工审核、切片、文案、封面、排期和执行记录已经打通 | +| 抖音 / B站发布架构 | 已实现 | 立即发送与定时发送共用 Scheduler、Registry、Windows Worker 和平台 Publisher | +| 真实账号投稿 | 需逐账号灰度确认 | 平台页面、登录态和风控会变化,必须先用单条低风险素材人工验收 | +| 运行形态 | Windows 本地单用户 | FastAPI + SQLite + 本地文件系统,不面向公网,不是多用户 SaaS | +| 自动化边界 | 保守执行 | 不绕过二维码、短信、验证码、滑块、登录失效、平台风控或人工确认 | + +完整主流程: + +```text +上传本地视频 / 选择 NAS 或本地已有视频 +→ E 盘任务目录与逐句时间戳转写 +→ DeepSeek / OpenAI-compatible 或 Ollama 分析高光 +→ 人工审核候选片段 +→ 保存选择并按需生成最新切片 +→ 自动准备标题、简介、话题和封面帧 +→ 发送中心核对内容与账号 +→ 立即发送或按北京时间排期 +→ Windows Chrome Worker 投稿抖音 / B站 +→ 成功、失败或人工复核记录 +``` + +## 当前功能 + +- **素材与存储**:支持浏览器上传、本地路径和 NAS 路径;生产视频、音频、转写、切片与发布包统一保存在配置的任务存储目录,默认使用 `E:\直播间切片工作流存储`。 +- **任务处理**:每条素材建立独立任务目录;全自动模式依次执行素材准备、转写、AI 分析、自动选片、视频切割、文案和发布任务创建。 +- **进度与恢复**:全自动任务详情每 3 秒局部更新状态、10 步进度、运行日志、候选数和输出数,不会刷新整页;失败后可从对应步骤重试或续跑。 +- **转写**:支持火山引擎远程转写和本地 faster-whisper,输出可复用的逐句时间戳原文。 +- **AI 选片**:支持远程 OpenAI-compatible / DeepSeek 与本地 Ollama;提供“通用内容价值”和“综艺笑点优先”模式,长内容可分段分析、去重和排序。 +- **审核与切片**:候选片段可启用、禁用并编辑标题、摘要和出入点;“保存并同步发送中心”会在选择变化或文件缺失时生成安全的新切片版本,一致时只做幂等同步。 +- **字幕**:保留独立字幕工作台和 ASS / FFmpeg 成片能力;v2.0 全自动主流程继续跳过字幕生成、烧录和叠加,避免把字幕步骤强绑到自动发布。 +- **内容准备**:按原始任务分组管理抖音 / B站内容,支持 AI 补齐标题简介、平台话题、候选封面帧、账号和可见范围;已排期卡片有明确状态与北京时间标记。 +- **排期计划**:支持批量预览、跨午夜每日窗口、续接当前平台最晚排期、月历日期详情、按时间升序、取消发送并返回内容准备。 +- **真实发送**:抖音和 B站使用各自 Publisher;默认 `local_browser`,`manual_export` 只做显式本地发布包导出,旧 `opencli_publish` 默认关闭。 +- **数据安全**:软删除与永久删除分开;永久删除只清理项目托管产物,外部唯一原片受保护,运行中的转写、切片或发送任务不能被删除。 + +## 发布与排期要点 - `platform` 只表示目标平台:`douyin` / `bilibili`。 - `publish_mode` 表示执行方式:默认 `local_browser`;`manual_export` 只能显式选择;旧 `opencli_publish` 只有设置 `PUBLISH_ENABLE_OPENCLI_FALLBACK=true` 才能执行。 @@ -48,17 +87,17 @@ v1.5.0 将抖音和 B站的立即发送、定时发送统一到 `PublishSchedule - 用户确认后,将预览返回的精确时间列表作为 `confirmed_schedule` 调用 `PATCH /api/publish/jobs/schedule-batch`,后端逐条校验后写库。 - 清除排期时提交 `action=clear`;普通任务回到 `WAITING`,`FAILED` 保持失败,`NEED_REVIEW` 保持复核状态。 -## 当前状态 +## 技术现状 -- 后端:FastAPI 可启动,当前 API 版本为 `1.5.0`。 +- 后端:FastAPI 可启动,当前 API 版本为 `2.0.0`。 - 前端:HTML + CSS + JavaScript + Jinja2 后台页面,已完成 Apple 风格全页面美化。 - 数据库:SQLite,保存任务、候选片段、输出片段、字幕任务、发送任务和 AI 配置等信息。 - 视频处理:已接入 FFmpeg / FFprobe,用于音频提取、切片、封面帧和字幕成片。 - 转写:支持火山引擎远程转写和本地 faster-whisper。 - AI 分析:支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama;长视频会按小段分析再合并候选片段。 - 发送中心:分为内容准备、排期计划、执行记录;抖音 / B站真实发布由统一 Scheduler 和 Windows Chrome Worker 执行。 -- 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会保存账号密码、cookie 或真实 API Key。 -- 配置安全:真实 `.env` 已被 Git 忽略,不会提交真实 API Key。 +- 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会要求保存平台账号密码。 +- 配置安全:真实 `.env`、数据库、视频、日志、浏览器 Profile、Cookie 和 storage state 均被 Git 忽略;平台登录态只保留在本机专属浏览器目录。 - 品牌说明:当前页面主名为“牛马片场”,英文代号为 `NiuMa Studio`,Docker 技术名为 `niuma-studio`。 ## 新手启动方式 @@ -116,13 +155,13 @@ http://127.0.0.1:8001 --- -## v1.5.0 统一真实发布 +## v2.0 统一真实发布 - 应用启动时会自动启动 `PublishScheduler`,默认每 5 秒扫描一次 `publish_jobs`。 - 默认发布方式是 `local_browser`;Docker 中的 FastAPI 通过 `PUBLISH_WORKER_URL=http://host.docker.internal:8765` 调用 Windows Worker。 - 日常启动只需打开 Docker Desktop 并运行 `niuma-studio`;Windows 后台观察器只在该容器运行时启动 Worker,项目停止 15 秒后自动关闭 Worker。 - `.\scripts\start_niuma_studio.ps1` 和 `.\scripts\start_publish_worker.ps1` 继续保留为开发、诊断或手动维护工具,不再是日常启动必需步骤。 -- 可选的 `manual_export` 会把发布包导出到 `outputs/publish_packages/{task_id}/{clip_id}/`;发布包包含 `clip.mp4`、`title.txt`、`caption.txt`、`hashtags.txt`、`cover_text.txt`、`publish_plan.json` 和 `metadata.json`,成功状态为 `EXPORTED`。 +- 可选的 `manual_export` 会把发布包导出到 `PUBLISH_SCHEDULER_EXPORT_DIR`,默认是 `E:\直播间切片工作流存储\_发布包`;发布包包含视频、标题、简介、话题、封面文字、发布计划和元数据,成功状态为 `EXPORTED`。 - 手动执行一次扫描: ```powershell diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 183fb12..d0df518 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # 第三方参考与许可证说明 -牛马片场 v1.5.0 的发布模块在设计阶段参考了以下开源项目和官方文档。项目没有整仓复制这些外部代码,平台 Publisher、Registry、调度器和 Windows Worker 均按牛马片场现有 FastAPI / SQLite 架构独立实现。 +牛马片场 v2.0.0 的发布模块在设计阶段参考了以下开源项目和官方文档。项目没有整仓复制这些外部代码,平台 Publisher、Registry、调度器和 Windows Worker 均按牛马片场现有 FastAPI / SQLite 架构独立实现。 ## social-auto-upload-web-ui diff --git a/VERSION b/VERSION index bc80560..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.0 +2.0.0 diff --git a/app/core/config.py b/app/core/config.py index d1ef0df..383d705 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -60,6 +60,10 @@ class Settings: data_dir: Path = _env_path("DATA_DIR", PROJECT_ROOT / "data") storage_root: Path = _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT) tasks_dir: Path = _env_path("TASKS_DIR", _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT)) + upload_temp_dir: Path = _env_path( + "UPLOAD_TEMP_DIR", + _env_path("TASKS_DIR", _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT)) / "_临时上传", + ) database_path: Path = _env_path( "DATABASE_PATH", _env_path("DATA_DIR", PROJECT_ROOT / "data") / "workflow.sqlite3", @@ -200,7 +204,7 @@ class Settings: publish_worker_allowed_roots: str = _env("PUBLISH_WORKER_ALLOWED_ROOTS", "") publish_scheduler_export_dir: Path = _env_path( "PUBLISH_SCHEDULER_EXPORT_DIR", - PROJECT_ROOT / "outputs" / "publish_packages", + _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT) / "_发布包", ) publish_scheduler_allow_publish_without_review: bool = _env_bool( "PUBLISH_SCHEDULER_ALLOW_PUBLISH_WITHOUT_REVIEW", diff --git a/app/main.py b/app/main.py index 387a2ee..22c4233 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ from contextlib import asynccontextmanager +import os +import tempfile from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse, JSONResponse @@ -8,6 +10,7 @@ from app.db.database import init_db from app.routers import ai_prompts, files, media, pages, publish, settings as settings_router, tasks from app.services.publish_scheduler import start_scheduler_background +from app.services.storage_service import configure_runtime_media_storage # /media 和 /static 的 Origin 白名单 @@ -43,6 +46,9 @@ def _build_allow_origin_header(origin: str) -> str: @asynccontextmanager async def lifespan(app: FastAPI): + previous_temp = tempfile.tempdir + previous_temp_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + app.state.media_storage = configure_runtime_media_storage() init_db() scheduler = await start_scheduler_background() app.state.publish_scheduler = scheduler @@ -51,12 +57,18 @@ async def lifespan(app: FastAPI): finally: if scheduler: scheduler.stop() + tempfile.tempdir = previous_temp + for name, value in previous_temp_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value app = FastAPI( title=settings.app_name, description=settings.app_description, - version="1.5.0", + version="2.0.0", lifespan=lifespan, ) diff --git a/app/models/task.py b/app/models/task.py index f7844db..0ec6f43 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -55,8 +55,8 @@ class TaskCreate(BaseModel): auto_schedule_mode: Literal["default", "immediate", "interval", "daily_window"] = "default" auto_schedule_start_at: Optional[str] = Field(default="", max_length=80) auto_schedule_interval_hours: int = Field(default=3, ge=1, le=168) - auto_schedule_daily_start_time: str = Field(default="09:00", max_length=5) - auto_schedule_daily_end_time: str = Field(default="21:00", max_length=5) + auto_schedule_daily_start_time: str = Field(default="07:00", max_length=5) + auto_schedule_daily_end_time: str = Field(default="00:00", max_length=5) auto_metadata_use_ai: bool = False @validator("auto_clip_count") @@ -194,8 +194,8 @@ class PublishBatchScheduleUpdate(BaseModel): start_at_local: Optional[str] = Field(default="", max_length=80) timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=80) interval_minutes: int = Field(default=180, ge=1, le=10080) - daily_start_time: str = Field(default="09:00", min_length=5, max_length=5) - daily_end_time: str = Field(default="21:00", min_length=5, max_length=5) + daily_start_time: str = Field(default="07:00", min_length=5, max_length=5) + daily_end_time: str = Field(default="00:00", min_length=5, max_length=5) confirmed_schedule: list[dict[str, str]] = Field(default_factory=list) @validator("job_ids") @@ -206,6 +206,22 @@ def validate_schedule_job_ids(cls, value: list[str]) -> list[str]: return normalized +class PublishScheduleNextStartRequest(BaseModel): + job_ids: list[str] = Field(default_factory=list) + platform: Literal["douyin", "bilibili"] + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=80) + interval_minutes: int = Field(default=180, ge=1, le=10080) + daily_start_time: str = Field(default="07:00", min_length=5, max_length=5) + daily_end_time: str = Field(default="00:00", min_length=5, max_length=5) + + @validator("job_ids") + def validate_next_start_job_ids(cls, value: list[str]) -> list[str]: + normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + if not normalized: + raise ValueError("至少选择一条发布任务") + return normalized + + class PublishJobContentUpdate(BaseModel): title: str = Field(..., min_length=1, max_length=120) caption: str = Field(..., min_length=1, max_length=2000) diff --git a/app/routers/publish.py b/app/routers/publish.py index 71a05e0..b23573c 100644 --- a/app/routers/publish.py +++ b/app/routers/publish.py @@ -18,6 +18,7 @@ PublishMarkPublishedRequest, PublishPlatformConfigUpdate, PublishRetryRequest, + PublishScheduleNextStartRequest, PublishSendJobUpdate, ) from app.services import publish_service @@ -419,6 +420,23 @@ async def preview_publish_jobs_schedule(payload: PublishBatchScheduleUpdate) -> raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/schedules/next-start") +async def get_publish_jobs_next_schedule_start(payload: PublishScheduleNextStartRequest) -> dict: + try: + return PublishScheduler().next_batch_schedule_start( + payload.job_ids, + platform=payload.platform, + timezone_name=payload.timezone, + interval_minutes=payload.interval_minutes, + daily_start_time=payload.daily_start_time, + daily_end_time=payload.daily_end_time, + ) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.patch("/jobs/{job_id}/content") async def update_publish_job_content(job_id: str, payload: PublishJobContentUpdate) -> dict: try: diff --git a/app/routers/tasks.py b/app/routers/tasks.py index eb2f4f1..163277e 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -19,7 +19,13 @@ from app.services import task_service from app.services.ai_prompt_preset_service import update_task_ai_prompt_preset from app.services.pipeline_engine import start_auto_pipeline -from app.services.storage_service import allocate_task_dir_name, save_uploaded_video +from app.services.storage_service import ( + allocate_task_dir_name, + remove_failed_task_directory, + save_uploaded_video, + StorageSafetyError, +) +from app.services.task_lifecycle_service import TaskDeletionConflictError from app.services import job_service from app.services import job_worker @@ -60,47 +66,58 @@ async def create_upload_task( auto_schedule_mode: str = Form("default"), auto_schedule_start_at: str | None = Form(""), auto_schedule_interval_hours: int = Form(3), - auto_schedule_daily_start_time: str = Form("09:00"), - auto_schedule_daily_end_time: str = Form("21:00"), + auto_schedule_daily_start_time: str = Form("07:00"), + auto_schedule_daily_end_time: str = Form("00:00"), auto_metadata_use_ai: bool = Form(False), video_file: UploadFile = File(...), ) -> dict: task_id = uuid4().hex[:12] task_dir_name = allocate_task_dir_name(task_name, exclude_task_id=task_id) - saved_path = save_uploaded_video( - task_id, - video_file.filename or "source_video.mp4", - video_file.file, - task_dir_name=task_dir_name, - ) - payload = TaskCreate( - task_name=task_name, - source_type="upload", - platform=platform, - original_video_path=str(saved_path), - max_clip_duration=max_clip_duration, - candidate_clip_count=candidate_clip_count, - selection_profile=selection_profile, - final_clip_target=final_clip_target, - ai_preference=ai_preference, - auto_mode=auto_mode, - auto_clip_count=auto_clip_count, - auto_min_clip_seconds=auto_min_clip_seconds, - auto_max_clip_seconds=auto_max_clip_seconds, - auto_schedule_mode=auto_schedule_mode, - auto_schedule_start_at=auto_schedule_start_at, - auto_schedule_interval_hours=auto_schedule_interval_hours, - auto_schedule_daily_start_time=auto_schedule_daily_start_time, - auto_schedule_daily_end_time=auto_schedule_daily_end_time, - auto_metadata_use_ai=auto_metadata_use_ai, - ) + task_record_created = False try: + saved_path = await run_in_threadpool( + save_uploaded_video, + task_id, + video_file.filename or "source_video.mp4", + video_file.file, + task_dir_name, + ) + payload = TaskCreate( + task_name=task_name, + source_type="upload", + platform=platform, + original_video_path=str(saved_path), + max_clip_duration=max_clip_duration, + candidate_clip_count=candidate_clip_count, + selection_profile=selection_profile, + final_clip_target=final_clip_target, + ai_preference=ai_preference, + auto_mode=auto_mode, + auto_clip_count=auto_clip_count, + auto_min_clip_seconds=auto_min_clip_seconds, + auto_max_clip_seconds=auto_max_clip_seconds, + auto_schedule_mode=auto_schedule_mode, + auto_schedule_start_at=auto_schedule_start_at, + auto_schedule_interval_hours=auto_schedule_interval_hours, + auto_schedule_daily_start_time=auto_schedule_daily_start_time, + auto_schedule_daily_end_time=auto_schedule_daily_end_time, + auto_metadata_use_ai=auto_metadata_use_ai, + ) result = task_service.create_task_record(payload, task_id=task_id, task_dir_name=task_dir_name) + task_record_created = True if payload.auto_mode: result["auto_pipeline"] = start_auto_pipeline(task_id, background_tasks=background_tasks) return result except ValueError as exc: + if not task_record_created: + await run_in_threadpool(remove_failed_task_directory, task_id, task_dir_name) raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + if not task_record_created: + await run_in_threadpool(remove_failed_task_directory, task_id, task_dir_name) + raise + finally: + await video_file.close() @router.get("/{task_id}") @@ -111,6 +128,14 @@ async def get_task_detail(task_id: str) -> dict: return task +@router.get("/{task_id}/live-status") +async def get_task_live_status(task_id: str) -> dict: + try: + return task_service.get_task_live_status(task_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.get("/{task_id}/transcript-status") async def get_transcript_status(task_id: str) -> dict: try: @@ -131,8 +156,14 @@ async def get_ai_analysis_status(task_id: str) -> dict: async def delete_task(task_id: str) -> dict: try: return task_service.soft_delete_task(task_id) + except TaskDeletionConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except StorageSafetyError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.patch("/{task_id}/status") @@ -293,6 +324,23 @@ async def batch_update_clip_candidates( raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/{task_id}/clips/sync-publish") +async def sync_reviewed_clips_to_publish_center( + task_id: str, + payload: ClipCandidateBatchUpdate, +) -> dict: + try: + return await run_in_threadpool( + task_service.sync_reviewed_clips_to_publish_center, + task_id, + payload.clips, + ) + except (ValueError, FileNotFoundError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @router.get("/{task_id}/clips/{clip_id}/transcript-excerpt") async def get_clip_transcript_excerpt( task_id: str, diff --git a/app/services/database_backup_service.py b/app/services/database_backup_service.py index 50d0db8..f868c51 100644 --- a/app/services/database_backup_service.py +++ b/app/services/database_backup_service.py @@ -17,6 +17,7 @@ PUBLISH_MIGRATION_JOURNAL_GLOB = f"{PUBLISH_MIGRATION_BACKUP_GLOB}-journal" PUBLISH_MIGRATION_BACKUP_COOLDOWN = timedelta(hours=24) PUBLISH_MIGRATION_BACKUP_KEEP_DAYS = 14 +MEDIA_CLEANUP_BACKUP_PREFIX = "workflow-before-media-cleanup-" BACKUP_TIMEZONE = ZoneInfo("Asia/Shanghai") @@ -262,3 +263,53 @@ def create_publish_migration_backup( backup_connection.close() if source_connection is not None: source_connection.close() + + +def create_media_cleanup_backup( + database_path: Path, + backup_dir: Path, + *, + now: datetime | None = None, +) -> Path: + """永久删除任务媒体前,原子创建一份仅包含 SQLite 元数据的备份。""" + database_path = database_path.resolve() + backup_dir = backup_dir.resolve() + now = now.astimezone(BACKUP_TIMEZONE) if now else datetime.now(BACKUP_TIMEZONE) + backup_dir.mkdir(parents=True, exist_ok=True) + + timestamp = now.strftime("%Y%m%d-%H%M%S-%f") + final_path = backup_dir / ( + f"{MEDIA_CLEANUP_BACKUP_PREFIX}{timestamp}-{os.getpid()}-{uuid4().hex[:8]}.sqlite3" + ) + temporary_path = final_path.with_name(f"{final_path.name}.tmp-{uuid4().hex}") + source_connection: sqlite3.Connection | None = None + backup_connection: sqlite3.Connection | None = None + try: + source_connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=10, + ) + backup_connection = sqlite3.connect(str(temporary_path), timeout=10) + source_connection.backup(backup_connection) + backup_connection.close() + backup_connection = None + source_connection.close() + source_connection = None + + integrity = sqlite_quick_check(temporary_path) + if integrity != "ok": + raise BackupSafetyError(f"媒体清理前备份完整性检查失败:{integrity}") + os.replace(temporary_path, final_path) + return final_path + except Exception as exc: + if temporary_path.exists(): + temporary_path.unlink() + if isinstance(exc, BackupSafetyError): + raise + raise BackupSafetyError(f"创建媒体清理前备份失败:{exc}") from exc + finally: + if backup_connection is not None: + backup_connection.close() + if source_connection is not None: + source_connection.close() diff --git a/app/services/job_service.py b/app/services/job_service.py index 859d8d9..b5455ef 100644 --- a/app/services/job_service.py +++ b/app/services/job_service.py @@ -23,12 +23,14 @@ JOB_STATUS_RUNNING = "running" JOB_STATUS_COMPLETED = "completed" JOB_STATUS_FAILED = "failed" +JOB_STATUS_CANCELLED = "cancelled" JOB_STATUS_LABELS = { JOB_STATUS_QUEUED: "排队中", JOB_STATUS_RUNNING: "运行中", JOB_STATUS_COMPLETED: "已完成", JOB_STATUS_FAILED: "失败", + JOB_STATUS_CANCELLED: "已取消", } JOB_TYPE_LABELS = { diff --git a/app/services/job_worker.py b/app/services/job_worker.py index 2909cde..074ff5c 100644 --- a/app/services/job_worker.py +++ b/app/services/job_worker.py @@ -17,6 +17,8 @@ def execute_job(job_id: str) -> dict: job = job_service.get_job(job_id) if not job: raise ValueError(f"job 不存在:{job_id}") + if job.get("status") == job_service.JOB_STATUS_CANCELLED: + return job job_type = job.get("job_type") task_id = job.get("task_id") diff --git a/app/services/pipeline_engine.py b/app/services/pipeline_engine.py index 3bdb0f9..7632f1f 100644 --- a/app/services/pipeline_engine.py +++ b/app/services/pipeline_engine.py @@ -13,6 +13,7 @@ from app.services.auto_publish_service import create_auto_publish_jobs, platforms_for_task from app.services.metadata_generator import MetadataGenerator from app.services.publish_service import generate_publish_cover_for_item +from app.services.publish_time import next_allowed_schedule_time from app.services.storage_service import ( create_task_directory, get_artifact_paths, @@ -63,8 +64,8 @@ "auto_schedule_mode": "default", "auto_schedule_start_at": "", "auto_schedule_interval_hours": 3, - "auto_schedule_daily_start_time": "09:00", - "auto_schedule_daily_end_time": "21:00", + "auto_schedule_daily_start_time": "07:00", + "auto_schedule_daily_end_time": "00:00", "auto_metadata_use_ai": False, } @@ -143,6 +144,8 @@ def _get_task(self, task_id: str) -> dict: task = task_service.get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") + if task.get("is_deleted"): + raise ValueError("任务已永久删除,已停止后续自动处理") return task def _load_auto_config(self, task: dict) -> dict: @@ -470,8 +473,8 @@ def _write_task_summary(self, task_id: str, status: str, error: str) -> dict: "publish_job_count": publish_job_count, "need_review_clips": need_review, "failures": failures, - "next_step": "打开发布中心人工确认发布任务;真正定时发送将在 v1.4.0 实现。", - "subtitle_note": "v1.3.0 全自动模式已跳过加字幕、字幕样式渲染和字幕烧录。", + "next_step": "打开发送中心核对内容、账号与北京时间;确认后可立即发送或设置排期。", + "subtitle_note": "v2.0 全自动模式继续跳过加字幕、字幕样式渲染和字幕烧录。", } summary_path = paths["analysis_path"].parent / "task_summary.json" summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") @@ -553,20 +556,27 @@ def build_schedule_times(count: int, config: dict, now: datetime | None = None) return [(start + index * interval).isoformat(timespec="seconds") for index in range(count)] if mode == "daily_window": - window_start = _parse_clock(str(config.get("auto_schedule_daily_start_time") or "09:00"), time(9, 0)) - window_end = _parse_clock(str(config.get("auto_schedule_daily_end_time") or "21:00"), time(21, 0)) + window_start = _parse_clock( + str(config.get("auto_schedule_daily_start_time") or "07:00"), + time(7, 0), + ).isoformat(timespec="minutes") + window_end = _parse_clock( + str(config.get("auto_schedule_daily_end_time") or "00:00"), + time(0, 0), + ).isoformat(timespec="minutes") scheduled = [] - cursor = start + cursor = next_allowed_schedule_time( + start, + daily_start_time=window_start, + daily_end_time=window_end, + ) while len(scheduled) < count: - day_start = datetime.combine(cursor.date(), window_start).replace(tzinfo=cursor.tzinfo) - day_end = datetime.combine(cursor.date(), window_end).replace(tzinfo=cursor.tzinfo) - if cursor < day_start: - cursor = day_start - if cursor > day_end: - cursor = datetime.combine(cursor.date() + timedelta(days=1), window_start).replace(tzinfo=cursor.tzinfo) - continue scheduled.append(cursor.isoformat(timespec="seconds")) - cursor = cursor + timedelta(hours=interval_hours) + cursor = next_allowed_schedule_time( + cursor + timedelta(hours=interval_hours), + daily_start_time=window_start, + daily_end_time=window_end, + ) return scheduled return [(start + index * timedelta(hours=3)).isoformat(timespec="seconds") for index in range(count)] diff --git a/app/services/publish_scheduler.py b/app/services/publish_scheduler.py index 98b6a4f..6e02529 100644 --- a/app/services/publish_scheduler.py +++ b/app/services/publish_scheduler.py @@ -29,6 +29,7 @@ build_schedule_times, ensure_future, local_display, + next_allowed_schedule_time, parse_datetime, to_utc_iso, utc_now, @@ -735,6 +736,74 @@ def preview_batch_schedule( ) return {"status": "ok", "timezone": timezone_name, "schedule": schedule} + def next_batch_schedule_start( + self, + job_ids: list[str], + *, + platform: str, + timezone_name: str = "Asia/Shanghai", + interval_minutes: int = 180, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", + ) -> dict[str, Any]: + ids = self._validate_batch_jobs(job_ids, platform) + placeholders = ",".join("?" for _ in ids) + with get_connection() as connection: + rows = connection.execute( + f""" + SELECT id, status, scheduled_at + FROM publish_jobs + WHERE platform = ? + AND status IN ('WAITING', 'SCHEDULED', 'PUBLISHING') + AND TRIM(COALESCE(scheduled_at, '')) <> '' + AND id NOT IN ({placeholders}) + """, + [platform, *ids], + ).fetchall() + + now = utc_now() + candidates: list[tuple[datetime, str]] = [] + for row in rows: + try: + scheduled = parse_datetime(row["scheduled_at"]).astimezone(timezone.utc) + except ValueError: + continue + if scheduled > now: + candidates.append((scheduled, str(row["id"]))) + + if not candidates: + return { + "status": "empty", + "timezone": timezone_name, + "message": "当前平台暂无其他未来排期,请手动选择第 1 条发布时间。", + "latest_job_id": "", + "latest_scheduled_at_utc": "", + "latest_scheduled_at_local_display": "", + "next_start_at_utc": "", + "next_start_at_local": "", + "next_start_at_local_display": "", + } + + latest, latest_job_id = max(candidates, key=lambda item: item[0]) + zone = app_zone(timezone_name) + candidate = latest.astimezone(zone) + timedelta(minutes=interval_minutes) + next_start = next_allowed_schedule_time( + candidate, + daily_start_time=daily_start_time, + daily_end_time=daily_end_time, + ) + return { + "status": "ok", + "timezone": timezone_name, + "message": "已接在当前平台最晚排期后。", + "latest_job_id": latest_job_id, + "latest_scheduled_at_utc": to_utc_iso(latest), + "latest_scheduled_at_local_display": local_display(latest, timezone_name), + "next_start_at_utc": to_utc_iso(next_start), + "next_start_at_local": next_start.strftime("%Y-%m-%dT%H:%M"), + "next_start_at_local_display": local_display(next_start, timezone_name), + } + def update_batch_schedule( self, job_ids: list[str], @@ -744,8 +813,8 @@ def update_batch_schedule( start_at_local: str = "", timezone_name: str = "Asia/Shanghai", interval_minutes: int = 180, - daily_start_time: str = "09:00", - daily_end_time: str = "21:00", + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", confirmed_schedule: list[dict[str, str]] | None = None, ) -> dict[str, Any]: ids = self._validate_batch_jobs(job_ids, platform) diff --git a/app/services/publish_time.py b/app/services/publish_time.py index 52d4298..3da9274 100644 --- a/app/services/publish_time.py +++ b/app/services/publish_time.py @@ -92,14 +92,26 @@ def _next_allowed_schedule_time(cursor: datetime, window_start: time, window_end return datetime.combine(cursor.date(), window_start, tzinfo=cursor.tzinfo) +def next_allowed_schedule_time( + cursor: datetime, + *, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", +) -> datetime: + """把一个候选时间顺延到每日允许发布时段。""" + window_start = parse_clock(daily_start_time, "每日开始时间") + window_end = parse_clock(daily_end_time, "每日结束时间") + return _next_allowed_schedule_time(cursor, window_start, window_end) + + def build_schedule_times( count: int, *, start_at_local: str, timezone_name: str | None = None, interval_minutes: int = 180, - daily_start_time: str = "09:00", - daily_end_time: str = "21:00", + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", reject_past: bool = True, ) -> list[str]: if count <= 0: diff --git a/app/services/storage_service.py b/app/services/storage_service.py index aff4d1b..56065aa 100644 --- a/app/services/storage_service.py +++ b/app/services/storage_service.py @@ -1,7 +1,10 @@ +from dataclasses import dataclass from pathlib import Path, PureWindowsPath +import os import re import sqlite3 import shutil +import tempfile from typing import BinaryIO from uuid import uuid4 @@ -26,6 +29,62 @@ _PATH_TRAVERSAL_MARKERS = ("..", "~") +class StorageSafetyError(RuntimeError): + """存储路径不安全或不满足清理条件。""" + + +@dataclass(frozen=True) +class ManagedMediaTarget: + label: str + path: Path + + +@dataclass(frozen=True) +class TaskMediaCleanupPlan: + task_id: str + targets: tuple[ManagedMediaTarget, ...] + external_source_path: Path | None + + @property + def existing_targets(self) -> tuple[ManagedMediaTarget, ...]: + return tuple(target for target in self.targets if target.path.exists()) + + +@dataclass(frozen=True) +class TaskMediaCleanupResult: + deleted_paths: tuple[str, ...] + freed_bytes: int + external_source_preserved: bool + + +def _ensure_writable_directory(path: Path, label: str) -> Path: + try: + path.mkdir(parents=True, exist_ok=True) + probe_path = path / f".niuma-write-test-{uuid4().hex}" + probe_path.write_bytes(b"ok") + probe_path.unlink() + except OSError as exc: + raise RuntimeError(f"{label}不可用或不可写:{path};原因:{exc}") from exc + return path.resolve() + + +def configure_runtime_media_storage() -> dict[str, str]: + """准备大文件目录,并把当前应用进程的临时目录固定到存储盘。""" + tasks_dir = _ensure_writable_directory(settings.tasks_dir, "任务存储目录") + upload_temp_dir = _ensure_writable_directory(settings.upload_temp_dir, "上传临时目录") + export_dir = _ensure_writable_directory(settings.publish_scheduler_export_dir, "发布包目录") + + temp_value = str(upload_temp_dir) + os.environ["TEMP"] = temp_value + os.environ["TMP"] = temp_value + tempfile.tempdir = temp_value + return { + "tasks_dir": str(tasks_dir), + "upload_temp_dir": temp_value, + "publish_export_dir": str(export_dir), + } + + def _collect_allowed_roots() -> list[Path]: """收集所有允许访问的文件系统根目录。""" roots: list[Path] = [] @@ -107,6 +166,11 @@ def ensure_storage_root() -> Path: return settings.storage_root +def ensure_tasks_root() -> Path: + settings.tasks_dir.mkdir(parents=True, exist_ok=True) + return settings.tasks_dir + + def _storage_relative_parts(task_dir_name: str) -> tuple[str, ...]: return tuple(part for part in PureWindowsPath(task_dir_name).parts if part not in {"", "."}) @@ -159,14 +223,14 @@ def allocate_task_dir_name( base_name = sanitize_task_dir_name(task_name, fallback=exclude_task_id or "untitled") parent_parts = _storage_relative_parts(parent_dir_name or "") existing_names = _get_existing_task_dir_names(exclude_task_id=exclude_task_id) - root = ensure_storage_root().joinpath(*parent_parts) + root = ensure_tasks_root().joinpath(*parent_parts) root.mkdir(parents=True, exist_ok=True) for index in range(1, 1000): candidate_name = base_name if index == 1 else f"{base_name} ({index})" candidate_parts = (*parent_parts, candidate_name) relative_name = str(PureWindowsPath(*candidate_parts)) - candidate_path = ensure_storage_root().joinpath(*candidate_parts) + candidate_path = ensure_tasks_root().joinpath(*candidate_parts) if relative_name.lower() not in existing_names and not candidate_path.exists(): return relative_name @@ -308,10 +372,9 @@ def get_source_video_path(task: dict) -> Path | None: def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task_dir_name: str | None = None) -> Path: - create_task_directory(task_id, task_dir_name) - # 扩展名校验 _validate_upload_extension(filename) + create_task_directory(task_id, task_dir_name) safe_name = Path(filename or "source_video").name if not Path(safe_name).suffix: @@ -321,25 +384,176 @@ def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task # 流式写入 + 大小限制检查 max_size = settings.max_upload_size_bytes written = 0 - with output_path.open("wb") as target: - while True: - chunk = file_object.read(1024 * 1024) # 1MB chunks - if not chunk: - break - written += len(chunk) - if written > max_size: - # 删除已写入的部分 - try: - output_path.unlink() - except OSError: - pass - max_gb = max_size / (1024 * 1024 * 1024) - raise ValueError(f"上传文件超过大小限制({max_gb:.1f} GB)") - target.write(chunk) + try: + with output_path.open("wb") as target: + while True: + chunk = file_object.read(1024 * 1024) # 1MB chunks + if not chunk: + break + written += len(chunk) + if written > max_size: + max_gb = max_size / (1024 * 1024 * 1024) + raise ValueError(f"上传文件超过大小限制({max_gb:.1f} GB)") + target.write(chunk) + except Exception: + try: + output_path.unlink(missing_ok=True) + except OSError: + pass + raise return output_path +def remove_failed_task_directory(task_id: str, task_dir_name: str) -> None: + """仅清理本次尚未写入数据库的新任务目录。""" + if _fetch_task_dir_name(task_id): + return + task_dir = get_task_directory(task_id, task_dir_name) + tasks_root = settings.tasks_dir.resolve() + resolved = task_dir.resolve(strict=False) + try: + within_root = resolved.is_relative_to(tasks_root) + except AttributeError: # pragma: no cover - Python 3.8 兼容 + within_root = str(resolved).lower().startswith(str(tasks_root).lower() + os.sep) + if resolved == tasks_root or not within_root or task_dir.is_symlink(): + raise StorageSafetyError(f"拒绝清理不安全的任务目录:{task_dir}") + if task_dir.exists(): + shutil.rmtree(task_dir) + + +def _safe_relative_parts(value: str, label: str) -> tuple[str, ...]: + windows_path = PureWindowsPath(str(value or "").strip()) + parts = tuple(part for part in windows_path.parts if part not in {"", "."}) + if ( + not parts + or windows_path.is_absolute() + or windows_path.drive + or any(part in _PATH_TRAVERSAL_MARKERS for part in parts) + ): + raise StorageSafetyError(f"{label}包含不安全路径:{value}") + return parts + + +def _safe_managed_child(root: Path, parts: tuple[str, ...], label: str) -> Path: + resolved_root = root.resolve(strict=False) + candidate = root.joinpath(*parts) + resolved_candidate = candidate.resolve(strict=False) + try: + within_root = resolved_candidate.is_relative_to(resolved_root) + except AttributeError: # pragma: no cover - Python 3.8 兼容 + within_root = str(resolved_candidate).lower().startswith(str(resolved_root).lower() + os.sep) + if resolved_candidate == resolved_root or not within_root or candidate.is_symlink(): + raise StorageSafetyError(f"拒绝删除不安全的{label}:{candidate}") + return candidate + + +def _deduplicate_targets(targets: list[ManagedMediaTarget]) -> tuple[ManagedMediaTarget, ...]: + unique: list[ManagedMediaTarget] = [] + seen: set[str] = set() + for target in targets: + key = str(target.path.resolve(strict=False)).lower() + if key in seen: + continue + seen.add(key) + unique.append(target) + return tuple(unique) + + +def _path_is_within(path: Path, parent: Path) -> bool: + try: + return path.resolve(strict=False).is_relative_to(parent.resolve(strict=False)) + except (AttributeError, OSError, ValueError): + path_value = str(path.resolve(strict=False)).lower() + parent_value = str(parent.resolve(strict=False)).lower() + return path_value.startswith(parent_value + os.sep) + + +def build_task_media_cleanup_plan(task: dict, *, include_legacy: bool = True) -> TaskMediaCleanupPlan: + task_id = str(task.get("id") or "").strip() + task_id_parts = _safe_relative_parts(task_id, "任务 ID") + if len(task_id_parts) != 1: + raise StorageSafetyError(f"任务 ID 必须是单层目录名:{task_id}") + + task_dir_name = str(task.get("task_dir_name") or task_id) + task_parts = _safe_relative_parts(task_dir_name, "任务目录名") + task_dir = _safe_managed_child(settings.tasks_dir, task_parts, "任务目录") + targets = [ManagedMediaTarget("E 盘任务目录", task_dir)] + + export_dir = _safe_managed_child( + settings.publish_scheduler_export_dir, + task_id_parts, + "发布包目录", + ) + targets.append(ManagedMediaTarget("E 盘发布包目录", export_dir)) + + if include_legacy: + legacy_root = settings.project_root / "tasks" + legacy_values = [task_id] + if len(task_parts) == 1 and task_dir_name.lower() != task_id.lower(): + legacy_values.append(task_dir_name) + for legacy_value in legacy_values: + legacy_parts = _safe_relative_parts(legacy_value, "旧版任务目录名") + legacy_path = _safe_managed_child(legacy_root, legacy_parts, "旧版 C 盘任务目录") + targets.append(ManagedMediaTarget("旧版 C 盘任务目录", legacy_path)) + + managed_targets = _deduplicate_targets(targets) + source_path = get_source_video_path(task) + external_source_path = None + if source_path and source_path.exists(): + if not any(_path_is_within(source_path, target.path) for target in managed_targets): + external_source_path = source_path + + return TaskMediaCleanupPlan( + task_id=task_id, + targets=managed_targets, + external_source_path=external_source_path, + ) + + +def _directory_size_bytes(path: Path) -> int: + total = 0 + for child in path.rglob("*"): + try: + if child.is_file() and not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + return total + + +def task_media_cleanup_plan_size(plan: TaskMediaCleanupPlan) -> int: + return sum( + _directory_size_bytes(target.path) + for target in plan.existing_targets + if target.path.is_dir() and not target.path.is_symlink() + ) + + +def apply_task_media_cleanup_plan(plan: TaskMediaCleanupPlan) -> TaskMediaCleanupResult: + deleted_paths: list[str] = [] + freed_bytes = 0 + for target in plan.targets: + path = target.path + if not path.exists(): + continue + if path.is_symlink() or not path.is_dir(): + raise StorageSafetyError(f"拒绝删除异常的{target.label}:{path}") + size = _directory_size_bytes(path) + try: + shutil.rmtree(path) + except OSError as exc: + raise RuntimeError(f"删除{target.label}失败:{path};原因:{exc}") from exc + freed_bytes += size + deleted_paths.append(str(path)) + + return TaskMediaCleanupResult( + deleted_paths=tuple(deleted_paths), + freed_bytes=freed_bytes, + external_source_preserved=plan.external_source_path is not None, + ) + + def move_task_directory_to_trash(task_id: str, task_name: str, task_dir_name: str | None = None) -> tuple[str, Path]: current_dir_name = resolve_task_dir_name(task_id, task_dir_name) source_dir = get_task_directory(task_id, current_dir_name) diff --git a/app/services/task_lifecycle_service.py b/app/services/task_lifecycle_service.py index 7103e25..34a2323 100644 --- a/app/services/task_lifecycle_service.py +++ b/app/services/task_lifecycle_service.py @@ -8,10 +8,37 @@ from app.db.database import get_connection from app.models.task import TaskCreate, TaskStatus -from app.services.storage_service import allocate_task_dir_name, create_task_directory, validate_source_video_path +from app.services.storage_service import ( + apply_task_media_cleanup_plan, + allocate_task_dir_name, + build_task_media_cleanup_plan, + create_task_directory, + validate_source_video_path, +) from app.services.task_log_service import append_task_log +class TaskDeletionConflictError(RuntimeError): + """任务仍在执行,暂时不能删除其媒体文件。""" + + +ACTIVE_TASK_STATUSES = { + TaskStatus.CREATED.value, + TaskStatus.PREPARING_SOURCE.value, + TaskStatus.TRANSCRIBING.value, + TaskStatus.AI_ANALYZING.value, + TaskStatus.CLIP_SELECTING.value, + TaskStatus.VIDEO_CUTTING.value, + TaskStatus.METADATA_GENERATING.value, + TaskStatus.SCHEDULE_CREATING.value, + TaskStatus.PUBLISH_JOB_CREATING.value, + TaskStatus.audio_extracting.value, + TaskStatus.transcribing.value, + TaskStatus.ai_analyzing.value, + TaskStatus.cutting.value, +} + + def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir_name: str | None = None) -> dict: from app.services.task_service import _now_iso, get_status_label, STATUS_PROGRESS # noqa: F811 @@ -228,34 +255,109 @@ def update_task_selection_settings( } -def soft_delete_task(task_id: str) -> dict: +def delete_task_permanently(task_id: str) -> dict: from app.services.task_service import _now_iso, get_task # noqa: F811 task = get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") - if task.get("is_deleted"): - return { - "message": "任务已隐藏,无需重复操作。", - "task_id": task_id, - "task_dir": task["task_dir"], - } - + cleanup_plan = build_task_media_cleanup_plan(task) + existing_target_count = len(cleanup_plan.existing_targets) now = _now_iso() with get_connection() as connection: - connection.execute( - """ - UPDATE tasks - SET is_deleted = 1, deleted_at = ?, updated_at = ? - WHERE id = ? - """, - (now, now, task_id), - ) - connection.commit() - - append_task_log(task_id, "任务已从列表隐藏,文件未删除") + try: + connection.execute("BEGIN IMMEDIATE") + current = connection.execute( + "SELECT status, COALESCE(is_deleted, 0) AS is_deleted FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not current: + raise ValueError("任务不存在") + + if not current["is_deleted"] and str(current["status"] or "") in ACTIVE_TASK_STATUSES: + raise TaskDeletionConflictError("任务正在处理,请等待处理结束后再永久删除。") + + conflicting_task = connection.execute( + """ + SELECT id + FROM tasks + WHERE id != ? AND COALESCE(is_deleted, 0) = 0 + AND LOWER(COALESCE(task_dir_name, id)) = LOWER(?) + LIMIT 1 + """, + (task_id, str(task.get("task_dir_name") or task_id)), + ).fetchone() + if conflicting_task: + raise TaskDeletionConflictError( + "该目录仍被另一条有效任务使用,已拒绝删除以避免误删。" + ) + + running_job = connection.execute( + "SELECT id FROM workflow_jobs WHERE task_id = ? AND status = 'running' LIMIT 1", + (task_id,), + ).fetchone() + if running_job: + raise TaskDeletionConflictError("任务仍有后台切片工作正在运行,请等待结束后再删除。") + + publishing_job = connection.execute( + "SELECT id FROM publish_jobs WHERE task_id = ? AND status = 'PUBLISHING' LIMIT 1", + (task_id,), + ).fetchone() + if publishing_job: + raise TaskDeletionConflictError("任务正在向平台发送视频,请等待发送结束后再删除。") + + cleanup_result = apply_task_media_cleanup_plan(cleanup_plan) + connection.execute( + """ + UPDATE workflow_jobs + SET status = 'cancelled', progress = 100, + message = '任务已永久删除,排队任务已取消', + error_message = '任务已永久删除', finished_at = ?, updated_at = ? + WHERE task_id = ? AND status = 'queued' + """, + (now, now, task_id), + ) + connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', scheduled_at = '', next_attempt_at = NULL, + error_code = 'task_deleted', error_message = '任务已永久删除', + last_error = '任务已永久删除', history_hidden = 1, + finished_at = ?, updated_at = ? + WHERE task_id = ? + AND status NOT IN ('PUBLISHED', 'EXPORTED', 'NEED_REVIEW', 'CANCELLED') + """, + (now, now, task_id), + ) + connection.execute( + """ + UPDATE tasks + SET is_deleted = 1, deleted_at = COALESCE(deleted_at, ?), updated_at = ? + WHERE id = ? + """, + (now, now, task_id), + ) + connection.commit() + except Exception: + connection.rollback() + raise + + status = "already_deleted" if task.get("is_deleted") and existing_target_count == 0 else "deleted" + freed_mb = cleanup_result.freed_bytes / (1024 * 1024) + if status == "already_deleted": + message = "任务已经永久删除,当前没有残留的任务视频文件。" + else: + message = f"任务已永久删除,共释放约 {freed_mb:.1f} MB;数据库历史记录已隐藏保留。" return { - "message": "任务已隐藏,原视频、切片和任务目录都已保留。", + "status": status, "task_id": task_id, - "task_dir": task["task_dir"], + "freed_bytes": cleanup_result.freed_bytes, + "external_source_preserved": cleanup_result.external_source_preserved, + "deleted_paths": list(cleanup_result.deleted_paths), + "message": message, } + + +def soft_delete_task(task_id: str) -> dict: + """兼容旧调用名称;实际执行永久媒体删除并保留隐藏数据库记录。""" + return delete_task_permanently(task_id) diff --git a/app/services/task_query_service.py b/app/services/task_query_service.py index d1f93ff..9cb420d 100644 --- a/app/services/task_query_service.py +++ b/app/services/task_query_service.py @@ -407,6 +407,12 @@ def get_system_status_context() -> dict: return { "storage_root": str(settings.storage_root), "storage_exists": settings.storage_root.exists(), + "tasks_dir": str(settings.tasks_dir), + "tasks_dir_exists": settings.tasks_dir.exists(), + "upload_temp_dir": str(settings.upload_temp_dir), + "upload_temp_dir_exists": settings.upload_temp_dir.exists(), + "publish_export_dir": str(settings.publish_scheduler_export_dir), + "publish_export_dir_exists": settings.publish_scheduler_export_dir.exists(), "database_path": str(settings.database_path), "database_exists": settings.database_path.exists(), "ffmpeg_path": ffmpeg_path or "未找到", diff --git a/app/services/task_service.py b/app/services/task_service.py index 217a0be..9a6e193 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -185,6 +185,38 @@ TaskStatus.failed.value: 0, } +AUTO_PIPELINE_RUNNING_STATUSES = { + TaskStatus.CREATED.value, + TaskStatus.PREPARING_SOURCE.value, + TaskStatus.TRANSCRIBING.value, + TaskStatus.AI_ANALYZING.value, + TaskStatus.CLIP_SELECTING.value, + TaskStatus.VIDEO_CUTTING.value, + TaskStatus.METADATA_GENERATING.value, + TaskStatus.SCHEDULE_CREATING.value, + TaskStatus.PUBLISH_JOB_CREATING.value, + TaskStatus.transcribing.value, + TaskStatus.ai_analyzing.value, + TaskStatus.cutting.value, +} + +AUTO_PIPELINE_FAILED_STATUSES = { + TaskStatus.FAILED_PREPARING_SOURCE.value, + TaskStatus.FAILED_TRANSCRIBING.value, + TaskStatus.FAILED_AI_ANALYZING.value, + TaskStatus.FAILED_CLIP_SELECTING.value, + TaskStatus.FAILED_VIDEO_CUTTING.value, + TaskStatus.FAILED_METADATA_GENERATING.value, + TaskStatus.FAILED_SCHEDULE_CREATING.value, + TaskStatus.FAILED_PUBLISH_JOB_CREATING.value, +} + +AUTO_PIPELINE_RESUMABLE_STATUSES = { + TaskStatus.pending_review.value, + TaskStatus.completed_with_errors.value, + TaskStatus.failed.value, +} + PLATFORM_LABELS = { "douyin": "抖音", "bilibili": "B站", @@ -234,6 +266,10 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.FAILED_METADATA_GENERATING.value: 7, TaskStatus.FAILED_SCHEDULE_CREATING.value: 8, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 9, + TaskStatus.pending_review.value: 5, + TaskStatus.completed.value: 10, + TaskStatus.completed_with_errors.value: 10, + TaskStatus.failed.value: 1, } failed_statuses = { TaskStatus.FAILED_PREPARING_SOURCE.value, @@ -244,8 +280,14 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.FAILED_METADATA_GENERATING.value, TaskStatus.FAILED_SCHEDULE_CREATING.value, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value, + TaskStatus.completed_with_errors.value, + TaskStatus.failed.value, + } + completed_statuses = { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, } - completed_statuses = {TaskStatus.READY_TO_PUBLISH.value, TaskStatus.COMPLETED.value} current_index = status_step_index.get(status, 1) steps = [] for index, name in enumerate(AUTO_WORKFLOW_STEPS, start=1): @@ -523,6 +565,62 @@ def get_task(task_id: str, include_video_probe: bool = True) -> dict | None: return _row_to_task(row, include_video_probe=include_video_probe) if row else None +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 + should_poll = is_running or (auto_mode and status == TaskStatus.READY_TO_PUBLISH.value) + candidate_count = count_clip_candidates(task_id) + output_clip_count = int(task.get("output_clip_count") or 0) + + primary_action = "none" + if auto_mode: + if status in AUTO_PIPELINE_FAILED_STATUSES: + primary_action = "retry" + elif status in { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, + }: + primary_action = "publish" + elif status in AUTO_PIPELINE_RESUMABLE_STATUSES: + primary_action = "resume" + elif is_running: + primary_action = "processing" + + return { + "task_id": task_id, + "status": status, + "status_label": task["status_label"], + "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), + "log_lines": _read_task_log_tail(task_id), + "counts": { + "candidates": candidate_count, + "outputs": output_clip_count, + }, + "actions": { + "primary": primary_action, + "review": candidate_count > 0, + "publish": output_clip_count > 0 + and status + in { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, + }, + }, + } + + def list_clip_candidates(task_id: str) -> list[dict]: ai_source_label = get_task_ai_source_label(task_id) with get_connection() as connection: @@ -706,8 +804,30 @@ def update_clip_candidates_batch(task_id: str, payloads: list[ClipCandidateBatch validated.append((payload.id, _validate_clip_update(task, payload))) now = _now_iso() + changed_count = 0 with get_connection() as connection: for clip_id, data in validated: + current = connection.execute( + """ + SELECT title, start_time, end_time, duration_seconds, enabled, summary + FROM clip_candidates + WHERE id = ? AND task_id = ? AND is_deleted = 0 + """, + (clip_id, task_id), + ).fetchone() + if current is None: + raise ValueError(f"候选片段不存在:{clip_id}") + if any( + ( + str(current["title"] or "") != data["title"], + str(current["start_time"] or "") != data["start_time"], + str(current["end_time"] or "") != data["end_time"], + int(current["duration_seconds"] or 0) != int(data["duration_seconds"]), + int(current["enabled"] or 0) != int(data["enabled"]), + str(current["summary"] or "") != data["summary"], + ) + ): + changed_count += 1 cursor = connection.execute( """ UPDATE clip_candidates @@ -735,11 +855,90 @@ def update_clip_candidates_batch(task_id: str, payloads: list[ClipCandidateBatch _append_task_log(task_id, f"已批量保存 {len(validated)} 条候选片段审核修改") return { "message": f"已保存 {len(validated)} 条候选片段,任务状态仍保持 AI 结果待检查。", + "changed_count": changed_count, "task": get_task(task_id, include_video_probe=False), "clips": list_clip_candidates(task_id), } +def _active_outputs_match_enabled_candidates(task_id: str) -> bool: + with get_connection() as connection: + enabled_rows = connection.execute( + """ + SELECT id + FROM clip_candidates + WHERE task_id = ? AND enabled = 1 AND is_deleted = 0 + """, + (task_id,), + ).fetchall() + output_rows = connection.execute( + """ + SELECT clip_candidate_id, output_file_path + FROM output_clip + WHERE task_id = ? AND is_active = 1 AND status = 'completed' + """, + (task_id,), + ).fetchall() + + enabled_ids = {str(row["id"]) for row in enabled_rows} + if not enabled_ids or len(output_rows) != len(enabled_ids): + return False + + output_ids = {str(row["clip_candidate_id"] or "") for row in output_rows} + if output_ids != enabled_ids: + return False + + return all( + bool( + (resolved := resolve_video_file_path(str(row["output_file_path"] or ""))) + and resolved.exists() + and resolved.is_file() + ) + for row in output_rows + ) + + +def sync_reviewed_clips_to_publish_center( + task_id: str, + payloads: list[ClipCandidateBatchItem], +) -> dict: + save_result = update_clip_candidates_batch(task_id, payloads) + needs_regeneration = bool(save_result["changed_count"]) or not _active_outputs_match_enabled_candidates(task_id) + + if needs_regeneration: + cut_result = process_task_video_cuts(task_id) + publish_sync = cut_result.get("publish_sync") or { + "status": "partial", + "message": "最新切片已生成,但没有取得发送中心同步结果。", + "errors": ["发送中心同步结果缺失"], + } + action_message = "已保存当前审核选择,并重新生成最新切片。" + else: + from app.services.publish_service import sync_task_publish_jobs + + cut_result = None + publish_sync = sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=True, + ) + action_message = "已保存当前审核选择,现有切片与选择一致,无需重复生成。" + + return { + "status": publish_sync.get("status") or "ok", + "message": f"{action_message}{publish_sync.get('message') or '发送中心同步完成。'}", + "regenerated": needs_regeneration, + "saved_count": len(payloads), + "changed_count": save_result["changed_count"], + "review_save": save_result, + "cut_result": cut_result, + "publish_sync": publish_sync, + "link_state": publish_sync.get("link_state") or {}, + "errors": publish_sync.get("errors") or [], + "warnings": publish_sync.get("warnings") or [], + } + + def get_clip_candidate(task_id: str, clip_id: str) -> dict: for clip in list_clip_candidates(task_id): if clip["id"] == clip_id: diff --git a/app/static/css/styles.css b/app/static/css/styles.css index fc29a21..9244ac8 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1173,6 +1173,28 @@ fieldset input.visually-hidden-file { .metro-legend .current { background: var(--blue); } .metro-legend .warning { background: var(--amber); } +.live-status-note { + margin: 12px 0 0; + color: var(--muted); + font-size: 12px; +} + +.live-status-note[data-state="active"] { + color: var(--blue); +} + +.live-status-note[data-state="completed"] { + color: var(--green); +} + +.live-status-note[data-state="error"] { + color: var(--amber); +} + +[data-live-task-actions] [hidden] { + display: none; +} + .detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(340px, 0.72fr); @@ -5078,6 +5100,11 @@ td a, .schedule-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; } .schedule-drawer form { display: grid; gap: 16px; } .schedule-drawer form > label, .schedule-window-grid label { display: grid; gap: 7px; } +.schedule-start-field, .schedule-start-field > label { display: grid; gap: 7px; } +.schedule-after-latest-button { justify-self: start; margin-top: 2px; } +.schedule-latest-note { margin: 2px 0 0; line-height: 1.55; } +.schedule-latest-note.tone-red { color: var(--red); } +.schedule-latest-note.tone-blue { color: var(--blue); } .schedule-window-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .schedule-window-help { margin: 8px 0 0; line-height: 1.55; } .schedule-feedback { margin: 0; } @@ -5099,7 +5126,7 @@ td a, .publish-history-row { grid-template-columns: 1fr 1fr; } } -/* v1.5 发送中心:内容准备、排期计划、执行记录 */ +/* v2.0 发送中心:内容准备、排期计划、执行记录 */ .publish-content-list, .publish-plan-list, .publish-execution-list { @@ -5167,6 +5194,13 @@ td a, border: 1px solid rgba(126, 151, 187, 0.18); border-radius: 18px; background: rgba(255, 255, 255, 0.84); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.publish-content-card.is-scheduled { + border-color: rgba(31, 111, 255, 0.32); + background: linear-gradient(135deg, rgba(244, 249, 255, 0.98), rgba(255, 255, 255, 0.90)); + box-shadow: inset 3px 0 0 rgba(31, 111, 255, 0.72), 0 12px 30px rgba(31, 111, 255, 0.08); } .publish-content-card[hidden], @@ -5195,6 +5229,35 @@ td a, flex-wrap: wrap; gap: 10px; } +.publish-schedule-badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 4px 10px; + border: 1px solid rgba(31, 111, 255, 0.18); + border-radius: 999px; + background: linear-gradient(180deg, rgba(236, 244, 255, 0.98), rgba(225, 238, 255, 0.94)); + color: #1559be; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); + font-size: 0.78rem; + white-space: nowrap; +} +.publish-schedule-badge[hidden] { display: none; } +.publish-schedule-badge strong { overflow: visible; color: var(--blue); } +.publish-schedule-badge time { + padding-left: 6px; + border-left: 1px solid rgba(31, 111, 255, 0.18); + color: #476a9e; + font-variant-numeric: tabular-nums; +} +.publish-schedule-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--blue); + box-shadow: 0 0 0 3px rgba(31, 111, 255, 0.12); +} .text-button.danger { color: var(--red); } .publish-content-grid, @@ -5309,14 +5372,31 @@ td a, .publish-calendar-weekdays, .publish-calendar-grid { min-width: 700px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } .publish-calendar-weekdays span { padding: 7px 8px; color: var(--muted); text-align: center; font-size: 0.78rem; } .publish-calendar-grid { overflow: hidden; border-top: 1px solid rgba(126, 151, 187, 0.18); border-left: 1px solid rgba(126, 151, 187, 0.18); border-radius: 12px; } -.publish-calendar-day { min-height: 96px; padding: 8px; border-right: 1px solid rgba(126, 151, 187, 0.18); border-bottom: 1px solid rgba(126, 151, 187, 0.18); background: rgba(255, 255, 255, 0.82); } +.publish-calendar-day { min-height: 96px; padding: 8px; border-right: 1px solid rgba(126, 151, 187, 0.18); border-bottom: 1px solid rgba(126, 151, 187, 0.18); background: rgba(255, 255, 255, 0.82); transition: background 160ms ease, box-shadow 160ms ease; } .publish-calendar-day.is-outside { color: #a9b4c5; background: rgba(244, 247, 251, 0.72); } .publish-calendar-day.is-today { box-shadow: inset 0 0 0 2px rgba(38, 118, 255, 0.45); } +.publish-calendar-day.is-has-jobs { cursor: pointer; } +.publish-calendar-day.is-has-jobs:hover { background: rgba(242, 248, 255, 0.96); } +.publish-calendar-day.is-has-jobs:focus-visible { position: relative; z-index: 1; outline: 3px solid rgba(38, 118, 255, 0.28); outline-offset: -3px; } +.publish-calendar-day.is-selected { position: relative; z-index: 1; background: rgba(235, 245, 255, 0.98); box-shadow: inset 0 0 0 2px var(--blue); } .calendar-day-number { display: inline-grid; place-items: center; width: 26px; height: 26px; margin-bottom: 3px; border-radius: 8px; font-size: 0.82rem; } .publish-calendar-day.is-today .calendar-day-number { color: #fff; background: var(--blue); } -.calendar-job-chip { display: block; width: 100%; margin-top: 4px; padding: 5px 6px; overflow: hidden; border: 0; border-radius: 7px; color: #1459b8; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 0.7rem; background: #e8f2ff; cursor: pointer; } -.calendar-job-chip:hover { background: #d9eaff; } +.calendar-job-chip { display: block; width: 100%; margin-top: 4px; padding: 5px 6px; overflow: hidden; border-radius: 7px; color: #1459b8; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 0.7rem; background: #e8f2ff; } +.publish-calendar-day.is-has-jobs:hover .calendar-job-chip { background: #d9eaff; } .calendar-job-more { display: block; margin-top: 5px; color: var(--muted); font-size: 0.68rem; } +.publish-calendar-day-detail { display: grid; gap: 12px; margin-top: 16px; padding: 16px; border: 1px solid rgba(38, 118, 255, 0.2); border-radius: 14px; background: linear-gradient(145deg, rgba(240, 247, 255, 0.96), rgba(255, 255, 255, 0.92)); box-shadow: 0 14px 34px rgba(38, 118, 255, 0.08); } +.publish-calendar-day-detail[hidden] { display: none; } +.publish-calendar-day-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.publish-calendar-day-detail h4 { margin: 2px 0 4px; font-size: 1rem; } +.publish-calendar-day-detail small { color: var(--muted); } +.publish-calendar-day-list { display: grid; gap: 8px; } +.publish-calendar-day-item { display: grid; grid-template-columns: 68px minmax(0, 1fr) auto; gap: 12px; align-items: center; width: 100%; padding: 11px 12px; border: 1px solid rgba(126, 151, 187, 0.16); border-radius: 11px; color: var(--text); text-align: left; background: rgba(255, 255, 255, 0.92); cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; } +.publish-calendar-day-item:hover { border-color: rgba(38, 118, 255, 0.32); box-shadow: 0 8px 20px rgba(38, 118, 255, 0.08); transform: translateY(-1px); } +.publish-calendar-day-item:focus-visible { outline: 3px solid rgba(38, 118, 255, 0.24); outline-offset: 2px; } +.publish-calendar-day-item time { color: var(--blue); font-weight: 750; font-variant-numeric: tabular-nums; } +.publish-calendar-day-item > span:nth-child(2) { display: grid; gap: 3px; min-width: 0; } +.publish-calendar-day-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-calendar-day-action { color: var(--blue); font-size: 0.78rem; white-space: nowrap; } .publish-plan-section-heading { margin: 0 2px 14px; } .publish-plan-section-heading > div { min-width: 160px; } .publish-plan-section-heading > small { color: var(--muted); text-align: right; } @@ -5542,6 +5622,9 @@ td a, .publish-calendar-toolbar, .publish-plan-section-heading { align-items: flex-start; flex-direction: column; } .publish-plan-section-heading > small { text-align: left; } .calendar-navigation { width: 100%; } + .publish-calendar-day-detail > header { align-items: stretch; } + .publish-calendar-day-item { grid-template-columns: 56px minmax(0, 1fr); } + .publish-calendar-day-action { display: none; } .publish-content-card { grid-template-columns: 28px 1fr; } .publish-content-media { grid-column: 2; grid-template-columns: 1fr 1fr; } .publish-content-media video, .publish-content-media img { width: 100%; } diff --git a/app/static/js/app.js b/app/static/js/app.js index f491924..d3fbbb7 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -125,6 +125,11 @@ async function handleProcessAction(button) { } return; } + if (button.dataset.endpoint.includes("/process/auto-")) { + if (result) result.textContent = data.message || "全自动流程已启动,状态会在当前页面自动更新。"; + startTaskLiveStatusPolling(true); + return; + } window.location.reload(); } catch (error) { if (result) result.textContent = `处理失败:${error.message}`; @@ -339,7 +344,19 @@ const aiAnalysisProgressPercent = document.querySelector("#ai-analysis-progress- const aiAnalysisProgressBar = document.querySelector("#ai-analysis-progress-bar"); const runtimeLogState = document.querySelector("#runtime-log-state"); const runtimeLogLines = document.querySelector("#runtime-log-lines"); +const autoPipelineMonitor = document.querySelector("[data-auto-pipeline-monitor]"); +const taskLiveOverview = document.querySelector("[data-task-live-overview]"); +const taskLiveProgressBar = document.querySelector("[data-task-live-progress-bar]"); +const taskLiveProgressNumber = document.querySelector("[data-task-live-progress-number]"); +const taskLiveNote = document.querySelector("[data-task-live-note]"); +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]"); let aiStatusPollingTimer = null; +let taskLiveStatusTimer = null; +let taskLiveForcedPollingUntil = 0; +const TASK_LIVE_STATUS_INTERVAL_MS = 3000; function summarizeErrorMessage(message, maxLength = 220) { const text = String(message || "").replace(/\s+/g, " ").trim(); @@ -787,6 +804,19 @@ function collectClipReviewPayload() { })); } +async function persistClipReviewChanges() { + if (!clipReviewForm) throw new Error("当前页面没有可保存的候选片段"); + const taskId = clipReviewForm.dataset.taskId; + const response = await fetch(`/api/tasks/${taskId}/clips/batch-update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clips: collectClipReviewPayload() }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "保存失败"); + return data; +} + async function deleteClipCard(card, button) { if (!clipReviewForm || !card) return; const taskId = clipReviewForm.dataset.taskId; @@ -1152,15 +1182,7 @@ if (saveClipsButton && clipReviewForm) { showClipReviewMessage("正在保存候选片段修改...", "info"); try { - const response = await fetch(`/api/tasks/${taskId}/clips/batch-update`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clips: collectClipReviewPayload() }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "保存失败"); - } + const data = await persistClipReviewChanges(); showClipReviewMessage(data.message || "保存成功。", "success"); } catch (error) { showClipReviewMessage(`保存失败:${error.message}`, "error"); @@ -1388,9 +1410,12 @@ if (generateClipsButton) { const originalText = generateClipsButton.textContent; generateClipsButton.disabled = true; generateClipsButton.textContent = "正在确认..."; - showClipReviewMessage("正在请求生成切片...", "info"); + showClipReviewMessage("正在保存当前审核选择...", "info"); try { + await persistClipReviewChanges(); + generateClipsButton.textContent = "正在生成..."; + showClipReviewMessage("审核选择已保存,正在生成最新切片...", "info"); const response = await fetch(generateClipsButton.dataset.endpoint, { method: "POST" }); const data = await response.json(); if (!response.ok) { @@ -1438,10 +1463,29 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { button.disabled = true; button.textContent = "正在同步..."; try { - const data = await window.apiFetch( - `/api/publish/tasks/${encodeURIComponent(taskId)}/sync?prefer_subtitled=${preferSubtitled ? "true" : "false"}`, - { method: "POST" }, - ); + const syncReviewedClips = button.dataset.syncReviewedClips === "true"; + let data; + if (syncReviewedClips) { + const clips = collectClipReviewPayload(); + if (!clips.some((clip) => clip.enabled)) { + throw new Error("请至少启用一条候选片段后再同步发送中心"); + } + button.textContent = "正在保存并生成..."; + showClipReviewMessage("正在保存审核选择;如选择有变化,将自动生成最新切片并同步...", "info"); + data = await window.apiFetch( + `/api/tasks/${encodeURIComponent(taskId)}/clips/sync-publish`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clips }), + }, + ); + } else { + data = await window.apiFetch( + `/api/publish/tasks/${encodeURIComponent(taskId)}/sync?prefer_subtitled=${preferSubtitled ? "true" : "false"}`, + { method: "POST" }, + ); + } const summary = document.querySelector("[data-publish-link-summary]"); if (summary) { summary.innerHTML = `发送中心关联:${data.link_state?.label || "同步完成"}${data.message || ""}`; @@ -1450,6 +1494,14 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { if (!document.querySelector("#process-result")) { window.alert(data.message || "发送中心同步完成。"); } + if (syncReviewedClips && data.status !== "partial") { + const params = new URLSearchParams({ + task_id: taskId, + tab: "content", + publish_message: data.message || "发送中心同步完成。", + }); + window.location.assign(`/publish?${params.toString()}`); + } } catch (error) { const message = `同步发送中心失败:${error.message}`; showClipReviewMessage(message, "error"); @@ -1464,23 +1516,24 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { document.querySelectorAll(".js-hide-task").forEach((button) => { button.addEventListener("click", async () => { const taskTitle = button.dataset.taskTitle || "这条任务"; - const confirmed = window.confirm(`确认把“${taskTitle}”移入 E 盘回收站吗?\n\n这会从列表隐藏任务,并把对应项目文件夹移动到 E:\\直播间切片工作流存储\\_回收站,不会删除原视频、切片文件和任务目录。`); + const confirmed = window.confirm(`确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\nNAS 或任务目录外的原始视频不会被删除。`); if (!confirmed) return; const originalText = button.textContent; button.disabled = true; - button.textContent = "移动中..."; + button.textContent = "删除中..."; try { const response = await fetch(`/api/tasks/${button.dataset.taskId}`, { method: "DELETE" }); const data = await response.json(); if (!response.ok) { - throw new Error(data.detail || "移入回收站失败"); + throw new Error(data.detail || "永久删除失败"); } - window.alert(data.message || "任务已移入回收站。"); + const externalNotice = data.external_source_preserved ? "\n\n任务目录外的原始视频已保留。" : ""; + window.alert(`${data.message || "任务已永久删除。"}${externalNotice}`); window.location.reload(); } catch (error) { - window.alert(`移入回收站失败:${error.message}`); + window.alert(`永久删除失败:${error.message}`); } finally { button.disabled = false; button.textContent = originalText; @@ -1545,15 +1598,144 @@ function renderRuntimeLog(status) { completed: "已完成", failed: "失败", }; - runtimeLogState.textContent = labelMap[status.status] || status.task_status_label || "已刷新"; + runtimeLogState.textContent = status.status_label || labelMap[status.status] || status.task_status_label || "已刷新"; runtimeLogState.dataset.status = status.status || "idle"; } if (!runtimeLogLines) return; const lines = Array.isArray(status.log_lines) ? status.log_lines : []; - runtimeLogLines.textContent = lines.length ? lines.join("\n") : "暂无运行日志。点击 AI 分析后,这里会自动刷新。"; + runtimeLogLines.textContent = lines.length ? lines.join("\n") : "暂无运行日志。任务开始后,这里会自动刷新。"; runtimeLogLines.scrollTop = runtimeLogLines.scrollHeight; } +function formatTaskLiveRefreshTime() { + return new Date().toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +function renderTaskLiveActions(data) { + if (!taskLiveActions) return; + const actions = data.actions || {}; + let primaryAction = actions.primary || "none"; + if (primaryAction === "publish" && !actions.publish) { + primaryAction = "none"; + } + + taskLiveActions.querySelectorAll("[data-live-primary-action]").forEach((node) => { + node.hidden = node.dataset.livePrimaryAction !== primaryAction; + }); + + const reviewAction = taskLiveActions.querySelector("[data-live-review-action]"); + if (reviewAction) reviewAction.hidden = !actions.review; + const syncAction = taskLiveActions.querySelector("[data-live-sync-action]"); + if (syncAction) syncAction.hidden = Number(data.counts?.outputs || 0) <= 0; +} + +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 || "状态未知"; + }); + const headerStatus = document.querySelector("[data-task-live-header-status]"); + if (headerStatus) headerStatus.textContent = data.status_label || data.status || "状态未知"; + if (taskLiveProgressBar) taskLiveProgressBar.style.width = `${progress}%`; + if (taskLiveProgressNumber) taskLiveProgressNumber.textContent = `${progress}%`; + if (taskLiveUpdatedAt) taskLiveUpdatedAt.textContent = data.updated_at || "未知"; + + const candidateCount = Number(data.counts?.candidates || 0); + const outputCount = Number(data.counts?.outputs || 0); + if (taskLiveCandidateCount) taskLiveCandidateCount.textContent = `${candidateCount} 条`; + if (taskLiveOutputCount) taskLiveOutputCount.textContent = `${outputCount} 条`; + if (aiCandidateCountPill) aiCandidateCountPill.textContent = `${candidateCount} 条候选`; + + 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}"]`); + if (!node) return; + node.classList.remove("done", "current", "pending", "warning"); + node.classList.add(allowedStepStates.has(step.state) ? step.state : "pending"); + const number = node.querySelector("span"); + const label = node.querySelector("strong"); + if (number) number.textContent = step.index; + if (label) label.textContent = step.name; + }); + + renderRuntimeLog(data); + renderTaskLiveActions(data); + if (autoPipelineMonitor) { + autoPipelineMonitor.dataset.status = data.status || ""; + autoPipelineMonitor.dataset.running = data.should_poll ? "true" : "false"; + } + if (taskLiveNote) { + if (data.error_message) { + taskLiveNote.dataset.state = "error"; + taskLiveNote.textContent = `流程已暂停:${summarizeErrorMessage(data.error_message)}`; + } else if (data.should_poll) { + taskLiveNote.dataset.state = "active"; + taskLiveNote.textContent = `自动刷新中 · ${formatTaskLiveRefreshTime()} 已获取最新状态`; + } else { + taskLiveNote.dataset.state = "completed"; + taskLiveNote.textContent = `状态已更新 · ${formatTaskLiveRefreshTime()}`; + } + } +} + +function scheduleTaskLiveStatusPolling() { + if (!autoPipelineMonitor || document.hidden) return; + if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = window.setTimeout(() => { + pollTaskLiveStatus().catch(() => {}); + }, TASK_LIVE_STATUS_INTERVAL_MS); +} + +async function pollTaskLiveStatus() { + if (!autoPipelineMonitor) return null; + const taskId = autoPipelineMonitor.dataset.taskId; + if (!taskId) return null; + if (taskLiveStatusTimer) { + window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = null; + } + + try { + const data = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/live-status`); + renderTaskLiveStatus(data); + if (data.should_poll) taskLiveForcedPollingUntil = 0; + if (data.should_poll || Date.now() < taskLiveForcedPollingUntil) { + scheduleTaskLiveStatusPolling(); + } + return data; + } catch (error) { + if (taskLiveNote) { + taskLiveNote.dataset.state = "error"; + taskLiveNote.textContent = `自动更新暂时中断,正在重试:${summarizeErrorMessage(error.message)}`; + } + scheduleTaskLiveStatusPolling(); + return null; + } +} + +function startTaskLiveStatusPolling(forceRestart = false) { + if (!autoPipelineMonitor) return; + if (forceRestart) taskLiveForcedPollingUntil = Date.now() + 10000; + pollTaskLiveStatus().catch(() => {}); +} + +if (autoPipelineMonitor) { + startTaskLiveStatusPolling(); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = null; + return; + } + startTaskLiveStatusPolling(); + }); +} + async function pollAiAnalysisStatus(keepPolling = false) { if (!aiAnalysisForm) return null; const taskId = aiAnalysisForm.dataset.taskId; diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js index 391f1f0..df159b5 100644 --- a/app/static/js/publish-center.js +++ b/app/static/js/publish-center.js @@ -13,6 +13,8 @@ if (publishCenterRoot) { const drawerCount = document.querySelector("[data-drawer-count]"); const scheduleForm = document.querySelector("[data-schedule-form]"); const previewList = document.querySelector("[data-schedule-preview]"); + const latestScheduleButton = document.querySelector("[data-use-latest-schedule]"); + const latestScheduleNote = document.querySelector("[data-latest-schedule-note]"); const previewScheduleButton = document.querySelector("[data-preview-schedule]"); const scheduleFeedbackNode = document.querySelector("[data-schedule-feedback]"); const confirmScheduleButton = document.querySelector("[data-confirm-schedule]"); @@ -36,6 +38,11 @@ if (publishCenterRoot) { const historyNextButton = document.querySelector("[data-history-next]"); const calendarNode = document.querySelector("[data-schedule-calendar]"); const calendarTitle = document.querySelector("[data-calendar-title]"); + const calendarDayDetail = document.querySelector("[data-calendar-day-detail]"); + const calendarDayTitle = document.querySelector("[data-calendar-day-title]"); + const calendarDaySummary = document.querySelector("[data-calendar-day-summary]"); + const calendarDayList = document.querySelector("[data-calendar-day-list]"); + const scheduleListNode = document.querySelector(".publish-plan-list"); const scheduleEmpty = document.querySelector("[data-schedule-empty]"); const contentEmpty = document.querySelector("[data-content-empty]"); const platformListTitle = document.querySelector("[data-platform-list-title]"); @@ -45,6 +52,7 @@ if (publishCenterRoot) { let latestPreviewItems = []; let activePlatform = "douyin"; let calendarMonth = currentBeijingMonth(); + let selectedCalendarDate = ""; let historyMonth = currentBeijingMonth(); let historySelectedDate = ""; let historyDeletedView = false; @@ -58,6 +66,8 @@ if (publishCenterRoot) { let historyRefreshQueuedRecords = false; const selectedHistoryJobIds = new Set(); let scheduleRefreshFrame = 0; + let scheduleRowOrderSequence = 0; + const scheduleRowOrders = new WeakMap(); let workerAvailable = schedulerHealthNode?.dataset.workerAvailable === "true"; let workerMessage = document.querySelector("[data-worker-message]")?.textContent?.split(" · ")[0] || "Windows 发布 Worker 未连接"; @@ -143,6 +153,128 @@ if (publishCenterRoot) { return Array.from(document.querySelectorAll('[data-publish-row][data-section="schedule"]')); } + function scheduleRowOrder(row) { + if (!scheduleRowOrders.has(row)) { + scheduleRowOrders.set(row, scheduleRowOrderSequence); + scheduleRowOrderSequence += 1; + } + return scheduleRowOrders.get(row); + } + + function scheduleTimestamp(row) { + if (String(row?.dataset.status || "").toUpperCase() !== "SCHEDULED") return Number.POSITIVE_INFINITY; + const timestamp = Date.parse(row.querySelector("[data-row-schedule]")?.dataset.utc || ""); + return Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY; + } + + function sortScheduleRows() { + if (!scheduleListNode) return; + const rows = scheduleRows(); + rows.forEach(scheduleRowOrder); + rows.sort((first, second) => { + const firstTimestamp = scheduleTimestamp(first); + const secondTimestamp = scheduleTimestamp(second); + const firstScheduled = Number.isFinite(firstTimestamp); + const secondScheduled = Number.isFinite(secondTimestamp); + if (firstScheduled !== secondScheduled) return firstScheduled ? -1 : 1; + if (firstScheduled && firstTimestamp !== secondTimestamp) return firstTimestamp - secondTimestamp; + return scheduleRowOrder(first) - scheduleRowOrder(second); + }); + rows.forEach((row) => scheduleListNode.appendChild(row)); + } + + function scheduledJobsByDate() { + const jobsByDate = new Map(); + scheduleRows().forEach((row) => { + if ( + row.dataset.platform !== activePlatform + || row.dataset.status !== "SCHEDULED" + || row.dataset.outputActive === "false" + ) return; + const utcValue = row.querySelector("[data-row-schedule]")?.dataset.utc || ""; + const timestamp = Date.parse(utcValue); + const key = beijingDateKey(utcValue); + if (!key || !Number.isFinite(timestamp)) return; + if (!jobsByDate.has(key)) jobsByDate.set(key, []); + jobsByDate.get(key).push({ + id: row.dataset.jobId, + title: row.querySelector("[data-row-title]")?.textContent?.trim() || "未命名任务", + account: row.querySelector("[data-row-account]")?.textContent?.trim() || "未选择账号", + status: row.querySelector("[data-row-status]")?.textContent?.trim() || "已排期", + time: formatBeijingTimestamp(utcValue).slice(11), + timestamp, + order: scheduleRowOrder(row), + }); + }); + jobsByDate.forEach((jobs) => { + jobs.sort((first, second) => first.timestamp - second.timestamp || first.order - second.order); + }); + return jobsByDate; + } + + function calendarDateLabel(value) { + const [year, month, day] = String(value || "").split("-").map(Number); + return year && month && day ? `${year} 年 ${month} 月 ${day} 日` : "当天排期"; + } + + function closeCalendarDayDetail() { + selectedCalendarDate = ""; + if (calendarDayDetail) calendarDayDetail.hidden = true; + if (calendarDayList) calendarDayList.innerHTML = ""; + calendarNode?.querySelectorAll("[data-calendar-date]").forEach((cell) => { + cell.classList.remove("is-selected"); + cell.setAttribute("aria-selected", "false"); + }); + } + + function renderCalendarDayDetail(jobsByDate) { + if (!calendarDayDetail || !calendarDayList || !selectedCalendarDate) { + if (calendarDayDetail) calendarDayDetail.hidden = true; + return; + } + const jobs = jobsByDate.get(selectedCalendarDate) || []; + if (!jobs.length) { + closeCalendarDayDetail(); + return; + } + if (calendarDayTitle) calendarDayTitle.textContent = calendarDateLabel(selectedCalendarDate); + if (calendarDaySummary) { + calendarDaySummary.textContent = `${platformLabel()} · ${jobs.length} 条排期 · 按北京时间从早到晚`; + } + calendarDayList.innerHTML = ""; + jobs.forEach((job) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "publish-calendar-day-item"; + button.dataset.calendarDetailJob = job.id; + button.setAttribute("aria-label", `${job.time} ${job.title},定位到任务`); + const time = document.createElement("time"); + time.textContent = job.time; + const identity = document.createElement("span"); + const title = document.createElement("strong"); + title.textContent = job.title; + const meta = document.createElement("small"); + meta.textContent = `${job.account} · ${job.status}`; + identity.append(title, meta); + const action = document.createElement("span"); + action.className = "publish-calendar-day-action"; + action.textContent = "定位到任务"; + button.append(time, identity, action); + calendarDayList.appendChild(button); + }); + calendarDayDetail.hidden = false; + } + + function focusScheduleRow(jobId) { + const row = document.querySelector( + `[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(jobId || "")}"]`, + ); + if (!row || row.hidden) return; + row.scrollIntoView({ behavior: "smooth", block: "center" }); + row.classList.add("is-calendar-focus"); + window.setTimeout(() => row.classList.remove("is-calendar-focus"), 1600); + } + function setTaskGroupExpanded(group, expanded) { if (!group) return; group.dataset.expanded = expanded ? "true" : "false"; @@ -256,19 +388,7 @@ if (publishCenterRoot) { const gridStart = new Date(Date.UTC(year, month - 1, 1 - mondayOffset)); const todayParts = beijingDateParts(); const todayKey = `${todayParts.year}-${String(todayParts.month).padStart(2, "0")}-${String(todayParts.day).padStart(2, "0")}`; - const jobsByDate = new Map(); - scheduleRows().forEach((row) => { - if (row.dataset.platform !== activePlatform || row.dataset.status !== "SCHEDULED") return; - const utcValue = row.querySelector("[data-row-schedule]")?.dataset.utc || ""; - const key = beijingDateKey(utcValue); - if (!key) return; - if (!jobsByDate.has(key)) jobsByDate.set(key, []); - jobsByDate.get(key).push({ - id: row.dataset.jobId, - title: row.querySelector("[data-row-title]")?.textContent?.trim() || "未命名任务", - time: formatBeijingTimestamp(utcValue).slice(11), - }); - }); + const jobsByDate = scheduledJobsByDate(); calendarNode.innerHTML = ""; for (let index = 0; index < 42; index += 1) { const date = new Date(gridStart.getTime() + index * 86400000); @@ -283,15 +403,21 @@ if (publishCenterRoot) { cell.dataset.date = key; cell.classList.toggle("is-outside", cellMonth !== month); cell.classList.toggle("is-today", key === todayKey); + cell.classList.toggle("is-has-jobs", jobs.length > 0); + cell.classList.toggle("is-selected", jobs.length > 0 && key === selectedCalendarDate); + cell.setAttribute("aria-selected", jobs.length > 0 && key === selectedCalendarDate ? "true" : "false"); + if (jobs.length) { + cell.dataset.calendarDate = key; + cell.tabIndex = 0; + cell.setAttribute("aria-label", `${calendarDateLabel(key)},${jobs.length} 条排期,点击查看全部`); + } const number = document.createElement("span"); number.className = "calendar-day-number"; number.textContent = String(cellDay); cell.appendChild(number); jobs.slice(0, 2).forEach((job) => { - const chip = document.createElement("button"); - chip.type = "button"; + const chip = document.createElement("span"); chip.className = "calendar-job-chip"; - chip.dataset.calendarJob = job.id; chip.title = `${job.time} ${job.title}`; chip.textContent = `${job.time} ${job.title}`; cell.appendChild(chip); @@ -304,6 +430,7 @@ if (publishCenterRoot) { } calendarNode.appendChild(cell); } + renderCalendarDayDetail(jobsByDate); } function historyMonthKey() { @@ -693,6 +820,7 @@ if (publishCenterRoot) { } function refreshScheduleViews() { + sortScheduleRows(); renderPlatformSchedule(); renderCalendar(); } @@ -912,6 +1040,7 @@ if (publishCenterRoot) { activePlatform = next; if (changed) { selectedJobIds.clear(); + closeCalendarDayDetail(); historySelectedDate = ""; historyPage = 1; clearHistorySelection(); @@ -990,6 +1119,19 @@ if (publishCenterRoot) { readyNode.classList.toggle("tone-green", Boolean(job.content_complete)); readyNode.classList.toggle("tone-amber", !job.content_complete); } + const contentScheduleBadge = row.querySelector("[data-content-schedule]"); + if (contentScheduleBadge) { + const isScheduled = status === "SCHEDULED"; + const utcValue = job.scheduled_at_utc || job.scheduled_at || ""; + row.classList.toggle("is-scheduled", isScheduled); + contentScheduleBadge.hidden = !isScheduled; + const contentScheduleTime = contentScheduleBadge.querySelector("[data-content-schedule-time]"); + if (contentScheduleTime) { + contentScheduleTime.textContent = job.scheduled_at_display || (utcValue ? formatBeijingTimestamp(utcValue) : ""); + } + const addToPlanButton = row.querySelector("[data-add-to-plan]"); + if (addToPlanButton) addToPlanButton.textContent = isScheduled ? "调整排期" : "加入发布计划"; + } const restoreButton = row.querySelector("[data-restore-job]"); if (restoreButton && job.is_user_removed !== undefined) restoreButton.hidden = !job.is_user_removed; const timeNode = row.querySelector("[data-row-schedule]"); @@ -1028,8 +1170,8 @@ if (publishCenterRoot) { start_at_local: String(scheduleForm?.elements.start_at_local?.value || ""), timezone: APP_TIMEZONE, interval_minutes: preset === "custom" ? Number(scheduleForm?.elements.interval_minutes?.value || 180) : Number(preset), - daily_start_time: String(scheduleForm?.elements.daily_start_time?.value || "09:00"), - daily_end_time: String(scheduleForm?.elements.daily_end_time?.value || "21:00"), + daily_start_time: String(scheduleForm?.elements.daily_start_time?.value || "07:00"), + daily_end_time: String(scheduleForm?.elements.daily_end_time?.value || "00:00"), }; } @@ -1045,6 +1187,14 @@ if (publishCenterRoot) { scheduleFeedbackNode.classList.toggle("tone-blue", tone !== "error"); } + function showLatestScheduleNote(message = "", tone = "info") { + if (!latestScheduleNote) return; + latestScheduleNote.hidden = !message; + latestScheduleNote.textContent = message; + latestScheduleNote.classList.toggle("tone-red", tone === "error"); + latestScheduleNote.classList.toggle("tone-blue", tone !== "error"); + } + function invalidatePreview() { latestPreviewSignature = ""; latestPreviewItems = []; @@ -1061,6 +1211,7 @@ if (publishCenterRoot) { drawer.hidden = false; drawerBackdrop.hidden = false; document.body.classList.add("has-schedule-drawer"); + showLatestScheduleNote(); updateSelectionUi(); } @@ -1239,25 +1390,37 @@ if (publishCenterRoot) { document.querySelector("[data-calendar-previous]")?.addEventListener("click", () => { const previous = new Date(Date.UTC(calendarMonth.year, calendarMonth.month - 2, 1)); calendarMonth = { year: previous.getUTCFullYear(), month: previous.getUTCMonth() + 1 }; + closeCalendarDayDetail(); renderCalendar(); }); document.querySelector("[data-calendar-next]")?.addEventListener("click", () => { const next = new Date(Date.UTC(calendarMonth.year, calendarMonth.month, 1)); calendarMonth = { year: next.getUTCFullYear(), month: next.getUTCMonth() + 1 }; + closeCalendarDayDetail(); renderCalendar(); }); document.querySelector("[data-calendar-today]")?.addEventListener("click", () => { calendarMonth = currentBeijingMonth(); + closeCalendarDayDetail(); renderCalendar(); }); calendarNode?.addEventListener("click", (event) => { - const chip = event.target.closest("[data-calendar-job]"); - if (!chip) return; - const row = document.querySelector(`[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(chip.dataset.calendarJob)}"]`); - if (!row || row.hidden) return; - row.scrollIntoView({ behavior: "smooth", block: "center" }); - row.classList.add("is-calendar-focus"); - window.setTimeout(() => row.classList.remove("is-calendar-focus"), 1600); + const cell = event.target.closest("[data-calendar-date]"); + if (!cell) return; + selectedCalendarDate = cell.dataset.calendarDate || ""; + renderCalendar(); + calendarDayDetail?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + calendarNode?.addEventListener("keydown", (event) => { + const cell = event.target.closest("[data-calendar-date]"); + if (!cell || !["Enter", " "].includes(event.key)) return; + event.preventDefault(); + cell.click(); + }); + document.querySelector("[data-calendar-day-close]")?.addEventListener("click", closeCalendarDayDetail); + calendarDayList?.addEventListener("click", (event) => { + const item = event.target.closest("[data-calendar-detail-job]"); + if (item) focusScheduleRow(item.dataset.calendarDetailJob); }); historyFilter?.addEventListener("change", () => { historyPage = 1; @@ -1805,12 +1968,51 @@ if (publishCenterRoot) { } }); - scheduleForm?.addEventListener("input", invalidatePreview); + scheduleForm?.addEventListener("input", () => { + invalidatePreview(); + showLatestScheduleNote(); + }); scheduleForm?.elements.interval_preset?.addEventListener("change", () => { document.querySelector("[data-custom-interval]").hidden = scheduleForm.elements.interval_preset.value !== "custom"; }); + latestScheduleButton?.addEventListener("click", async () => { + const payload = schedulePayload("apply"); + const request = { + job_ids: payload.job_ids, + platform: payload.platform, + timezone: payload.timezone, + interval_minutes: payload.interval_minutes, + daily_start_time: payload.daily_start_time, + daily_end_time: payload.daily_end_time, + }; + latestScheduleButton.disabled = true; + latestScheduleButton.textContent = "正在查询当前最晚排期…"; + showLatestScheduleNote(); + showScheduleFeedback("正在读取当前平台的最新排期,请稍候。"); + try { + const data = await window.apiFetch("/api/publish/schedules/next-start", { + method: "POST", + body: JSON.stringify(request), + }); + invalidatePreview(); + if (data.status === "empty") { + showLatestScheduleNote(data.message || "当前平台暂无其他未来排期,请手动选择时间。"); + return; + } + scheduleForm.elements.start_at_local.value = data.next_start_at_local; + showLatestScheduleNote( + `当前最晚:${data.latest_scheduled_at_local_display};本次第 1 条:${data.next_start_at_local_display}`, + ); + } catch (error) { + showScheduleFeedback(`读取最晚排期失败:${error.message}`, "error"); + } finally { + latestScheduleButton.disabled = false; + latestScheduleButton.textContent = "接在当前平台最晚排期后"; + } + }); + previewScheduleButton?.addEventListener("click", async () => { const payload = schedulePayload("apply"); if (!payload.start_at_local || beijingInputToTimestamp(payload.start_at_local) <= Date.now()) { diff --git a/app/templates/base.html b/app/templates/base.html index 6cb2097..1562f15 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -9,7 +9,7 @@ - + {% block extra_head %}{% endblock %}
@@ -34,8 +34,8 @@ 系统状态 @@ -61,7 +61,7 @@ - + {% block extra_scripts %}{% endblock %}