From b4db1200f1b64e7534c3664baa0bc25e34d54779 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 21:22:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=8A=96?= =?UTF-8?q?=E9=9F=B3=E5=B7=B2=E6=8E=92=E6=9C=9F=E4=BB=BB=E5=8A=A1=E5=8F=AF?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_LOG.md | 7 +++ NEXT_STEPS.md | 9 +++ app/services/publish_service.py | 38 +++++++++---- app/static/js/app.js | 65 ++++++++++++++++++---- app/templates/base.html | 2 +- tests/test_publish_scheduler.py | 97 ++++++++++++++++++++++++++++++++- 6 files changed, 196 insertions(+), 22 deletions(-) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 7dbbde5..bec5300 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,12 @@ # Development Log +## 2026-07-01 抖音已排期任务手动立即发送修复 +- 修复发送中心“发送此条”对 `SCHEDULED` 抖音任务没有启动 opencli 的问题:用户显式点击单条发送或勾选后批量发送时,已排期任务会按本次手动操作立即进入 opencli 发送队列。 +- 保留“开始发送全部”的安全边界:未勾选任何任务时,只会发送 `WAITING` / `FAILED` 的 opencli 任务,不会把未来排期任务全部提前发送。 +- 发送中心前端新增更清晰的确认文案和错误反馈:已排期任务会提示“确认后立即发送,不再等待原计划时间”;空队列、opencli 缺失或发送队列忙碌时会关闭发布遮罩并恢复按钮。 +- 静态资源版本更新为 `20260701-douyin-scheduled-send`,确保浏览器加载新的发送中心脚本。 +- 新增测试覆盖:显式发送 `SCHEDULED` opencli 任务、未勾选批量不发送未来排期、`WAITING` / `FAILED` 任务仍可批量启动。 + ## 2026-06-25 全自动切片修复与发送中心批量排期 - 修复 AI 分析完成后候选片段“先写入、随后又被全部删除”的事务顺序错误;候选片段现在在单个 SQLite 事务内替换,任一新片段写入失败都会自动回滚并保留旧结果。 - 修复历史全自动任务卡在 AI 分析后的问题:任务可从最近一次 AI 分析历史恢复候选片段,并从自动选片阶段继续,不需要重新消耗一次 AI 分析。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 89f56d4..f50d75d 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,14 @@ # Next Steps +## 2026-07-01 抖音发送按钮修复后怎么测试 +1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。 +2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确保加载 `20260701-douyin-scheduled-send` 版脚本。 +3. 在发送中心点击“抖音”筛选,找一条状态为“待发送 / 定时”的抖音任务。 +4. 点击这条任务的“发送此条”;如果它已经有发布时间,确认框会提示本次会立即发送,不再等待原计划时间。 +5. 正常情况:页面会出现“正在发布”提示,Chrome / opencli 会打开抖音创作者页面并开始填写内容。 +6. 如果未勾选任何任务就点“开始发送全部”,系统只会发送等待处理或发送失败任务,不会提前发送未来排期任务。 +7. 如果页面提示 opencli 缺失、队列为空或正在发送中,按钮会恢复可点状态;按提示启动辅助服务或稍后重试即可。 + ## 2026-06-25 全自动流程修复后怎么测试 1. 打开 `http://127.0.0.1:8001/tasks/new`,按 `Ctrl + F5` 强制刷新一次。 2. 选择视频,填写“单条切片最长”和“候选片段数量”,勾选“新建后自动跑完整流水线”。 diff --git a/app/services/publish_service.py b/app/services/publish_service.py index e921901..5c3100f 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -2565,13 +2565,22 @@ def execute_opencli_send_job(job_id: str, runner: CommandRunner | None = None) - return {"status": "ok", "message": "opencli 发送流程已执行完成。", "job": get_publish_job(job_id)} -def _ready_opencli_job_ids(job_ids: list[str] | None = None) -> list[str]: - params: list[str] = [] - where = "publish_mode = 'opencli_publish' AND status IN ('WAITING', 'FAILED', 'ready', 'failed')" - if job_ids: - placeholders = ",".join("?" for _ in job_ids) +def _normalize_opencli_job_ids(job_ids: list[str] | None = None) -> list[str]: + return list(dict.fromkeys(str(job_id).strip() for job_id in (job_ids or []) if str(job_id).strip())) + + +def _ready_opencli_job_ids(job_ids: list[str] | None = None, *, include_scheduled: bool = False) -> list[str]: + normalized_ids = _normalize_opencli_job_ids(job_ids) + statuses = ["WAITING", "FAILED", "ready", "failed"] + if include_scheduled: + statuses.extend(["SCHEDULED", "scheduled"]) + params: list[str] = statuses.copy() + status_placeholders = ",".join("?" for _ in statuses) + where = f"publish_mode = 'opencli_publish' AND status IN ({status_placeholders})" + if normalized_ids: + placeholders = ",".join("?" for _ in normalized_ids) where += f" AND id IN ({placeholders})" - params.extend(job_ids) + params.extend(normalized_ids) with get_connection() as connection: rows = connection.execute( f"SELECT id FROM publish_jobs WHERE {where} ORDER BY created_at ASC", @@ -2580,11 +2589,18 @@ def _ready_opencli_job_ids(job_ids: list[str] | None = None) -> list[str]: return [row["id"] for row in rows] +def _empty_opencli_send_message(has_explicit_job_ids: bool) -> str: + if has_explicit_job_ids: + return "已勾选的任务里没有可立即发送的 opencli 任务;已发布、已取消、需复核或非网页发送任务不会启动。" + return "当前没有等待处理或失败可重试的 opencli 任务;已排期任务不会被“开始发送全部”自动发送,请勾选后再立即发送。" + + def run_opencli_send_batch(job_ids: list[str] | None = None, runner: CommandRunner | None = None) -> dict: if not _SEND_LOCK.acquire(blocking=False): return {"status": "busy", "message": "发送队列正在运行,请等待当前批次结束。", "jobs": list_publish_jobs(limit=100)} try: - ids = _ready_opencli_job_ids(job_ids) + normalized_ids = _normalize_opencli_job_ids(job_ids) + ids = _ready_opencli_job_ids(normalized_ids or None, include_scheduled=bool(normalized_ids)) results = [execute_opencli_send_job(job_id, runner=runner) for job_id in ids] return {"status": "ok", "message": f"发送批次已处理 {len(results)} 条任务。", "results": results, **get_publish_center_context()} finally: @@ -2592,9 +2608,10 @@ def run_opencli_send_batch(job_ids: list[str] | None = None, runner: CommandRunn def start_opencli_send_batch(payload: PublishSendStart, background_tasks: Any | None = None) -> dict: - ids = _ready_opencli_job_ids(payload.job_ids) + explicit_job_ids = _normalize_opencli_job_ids(payload.job_ids) + ids = _ready_opencli_job_ids(explicit_job_ids or None, include_scheduled=bool(explicit_job_ids)) if not ids: - return {"status": "empty", "message": "当前没有待发送或失败可重试的任务。", **get_publish_center_context()} + return {"status": "empty", "message": _empty_opencli_send_message(bool(explicit_job_ids)), **get_publish_center_context()} if _SEND_LOCK.locked(): return {"status": "busy", "message": "发送队列正在运行,请稍后刷新查看进度。", **get_publish_center_context()} opencli_status = _opencli_status() @@ -2609,7 +2626,8 @@ def start_opencli_send_batch(payload: PublishSendStart, background_tasks: Any | } if background_tasks is not None: background_tasks.add_task(run_opencli_send_batch, ids) - return {"status": "started", "message": f"已开始后台发送 {len(ids)} 条任务。", **get_publish_center_context()} + scheduled_note = " 已勾选任务中如包含定时任务,会按本次操作立即发送。" if explicit_job_ids else "" + return {"status": "started", "message": f"已开始后台发送 {len(ids)} 条任务。{scheduled_note}", **get_publish_center_context()} return run_opencli_send_batch(ids) diff --git a/app/static/js/app.js b/app/static/js/app.js index 543b51b..f972f58 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2453,6 +2453,24 @@ function activeSendJobIds() { return Array.from(document.querySelectorAll("[data-send-job-checkbox]:checked")).map((checkbox) => checkbox.value); } +function selectedSendJobStatusSummary() { + const checked = Array.from(document.querySelectorAll("[data-send-job-checkbox]:checked")); + return { + scheduledCount: checked.filter((checkbox) => { + const card = checkbox.closest("[data-send-card]"); + return (card?.dataset.status || "").toUpperCase() === "SCHEDULED"; + }).length, + }; +} + +function sendStartNeedsAttention(status) { + return ["empty", "missing_opencli", "busy"].includes(String(status || "")); +} + +function shouldReloadAfterSendStart(status) { + return ["started", "ok"].includes(String(status || "")); +} + function updateSendFilter(filter) { const normalizedFilter = (filter || "all").toLowerCase(); document.querySelectorAll("[data-send-card]").forEach((card) => { @@ -2646,12 +2664,21 @@ document.querySelectorAll("[data-send-single-job]").forEach((button) => { const form = button.closest("[data-send-job-form]"); const jobId = form?.dataset.jobId; if (!jobId) return; - if (!window.confirm("确认开始发送这一条吗?请先确认 Chrome 已登录对应平台。")) return; + const cardStatus = (form.closest("[data-send-card]")?.dataset.status || "").toUpperCase(); + const scheduledNotice = + cardStatus === "SCHEDULED" + ? "\n\n注意:这条任务已经设置了发布时间,确认后会立即发送,不再等待原计划时间。" + : ""; + if (!window.confirm(`确认开始发送这一条吗?请先确认 Chrome 已登录对应平台。${scheduledNotice}`)) return; const originalText = button.textContent; button.disabled = true; button.textContent = "发送中..."; updateSendPreviewFromForm(form); - showSendPublishingOverlay("正在发布", "正在发送这一条,opencli 会打开平台页面并自动填写内容。"); + const sendingText = + cardStatus === "SCHEDULED" + ? "这条定时任务正在按本次手动操作立即发送,opencli 会打开平台页面并自动填写内容。" + : "正在发送这一条,opencli 会打开平台页面并自动填写内容。"; + showSendPublishingOverlay("正在发布", sendingText); setSendCenterMessage("已提交单条发送任务,opencli 会使用 Chrome 登录态打开平台页面。"); try { @@ -2660,11 +2687,15 @@ document.querySelectorAll("[data-send-single-job]").forEach((button) => { if (!response.ok) { throw new Error(data.detail || data.message || "发送启动失败"); } - if (data.status === "empty") { + if (sendStartNeedsAttention(data.status)) { hideSendPublishingOverlay(); + button.disabled = false; + button.textContent = originalText; + } + setSendCenterMessage(data.message || "发送任务已开始。", sendStartNeedsAttention(data.status) ? "error" : "success"); + if (shouldReloadAfterSendStart(data.status)) { + reloadSendCenter(1400); } - setSendCenterMessage(data.message || "发送任务已开始。", "success"); - reloadSendCenter(1400); } catch (error) { hideSendPublishingOverlay(); setSendCenterMessage(`发送启动失败:${error.message}`, "error"); @@ -2677,12 +2708,22 @@ document.querySelectorAll("[data-send-single-job]").forEach((button) => { document.querySelectorAll("[data-start-send-queue]").forEach((button) => { button.addEventListener("click", async () => { const selectedIds = activeSendJobIds(); + const selectedStatusSummary = selectedSendJobStatusSummary(); const label = selectedIds.length ? `${selectedIds.length} 条已勾选任务` : "全部待发送/失败任务"; - if (!window.confirm(`确认开始发送 ${label} 吗?\n\n请先确认 Chrome 已登录抖音创作者中心和 B站创作中心。`)) return; + const scheduledNotice = + selectedIds.length && selectedStatusSummary.scheduledCount + ? `\n\n其中 ${selectedStatusSummary.scheduledCount} 条已排期任务会立即发送,不再等待原计划时间。` + : "\n\n未勾选时只会发送等待处理/发送失败任务,不会发送未来排期任务。"; + if (!window.confirm(`确认开始发送 ${label} 吗?\n\n请先确认 Chrome 已登录抖音创作者中心和 B站创作中心。${scheduledNotice}`)) return; const originalText = button.textContent; button.disabled = true; button.textContent = "启动中..."; - showSendPublishingOverlay("正在发布", selectedIds.length ? `正在发送 ${selectedIds.length} 条已勾选任务,一次只会执行一条。` : "正在发送全部待发送任务,一次只会执行一条。"); + showSendPublishingOverlay( + "正在发布", + selectedIds.length + ? `正在发送 ${selectedIds.length} 条已勾选任务,一次只会执行一条。` + : "正在发送全部等待处理/发送失败任务,一次只会执行一条。" + ); setSendCenterMessage("正在启动发送队列,一次只会执行一条任务。"); try { @@ -2695,11 +2736,15 @@ document.querySelectorAll("[data-start-send-queue]").forEach((button) => { if (!response.ok) { throw new Error(data.detail || data.message || "启动队列失败"); } - if (data.status === "empty") { + if (sendStartNeedsAttention(data.status)) { hideSendPublishingOverlay(); + button.disabled = false; + button.textContent = originalText; + } + setSendCenterMessage(data.message || "发送队列已启动。", sendStartNeedsAttention(data.status) ? "error" : "success"); + if (shouldReloadAfterSendStart(data.status)) { + reloadSendCenter(1400); } - setSendCenterMessage(data.message || "发送队列已启动。", data.status === "busy" ? "error" : "success"); - reloadSendCenter(1400); } catch (error) { hideSendPublishingOverlay(); setSendCenterMessage(`启动队列失败:${error.message}`, "error"); diff --git a/app/templates/base.html b/app/templates/base.html index a905f14..464a98d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -61,6 +61,6 @@ {% block extra_scripts %}{% endblock %} - + diff --git a/tests/test_publish_scheduler.py b/tests/test_publish_scheduler.py index 598cb51..ba6a294 100644 --- a/tests/test_publish_scheduler.py +++ b/tests/test_publish_scheduler.py @@ -12,6 +12,8 @@ from app.core.config import settings from app.db.database import get_connection, init_db +from app.models.task import PublishSendStart +from app.services import publish_service from app.services.auto_publish_service import create_auto_publish_jobs from app.services.publish_scheduler import PublishScheduler, build_batch_schedule_times from app.services.publish_service import get_publish_job @@ -65,6 +67,8 @@ def _insert_job( title: str = "测试标题", caption: str = "测试文案", risk_flags: list[str] | None = None, + platform: str = "manual_export", + publish_mode: str = "manual_export", ) -> str: task_id = f"{TEST_PREFIX}{uuid4().hex[:8]}" clip_id = f"{TEST_PREFIX}clip-{uuid4().hex[:8]}" @@ -97,7 +101,7 @@ def _insert_job( tags, hashtags, cover_text, risk_flags, scheduled_at, status, created_at, updated_at ) - VALUES (?, ?, ?, ?, 'manual_export', 'manual_export', + VALUES (?, ?, ?, ?, ?, ?, 'original', ?, ?, ?, ?, ?, '#测试', '#测试', '封面文案', ?, ?, ?, ?, ?) """, ( @@ -105,6 +109,8 @@ def _insert_job( task_id, clip_id, clip_id, + platform, + publish_mode, video, video, title, @@ -121,6 +127,95 @@ def _insert_job( return job_id +class FakeBackgroundTasks: + def __init__(self) -> None: + self.tasks: list[tuple[object, tuple, dict]] = [] + + def add_task(self, func, *args, **kwargs) -> None: + self.tasks.append((func, args, kwargs)) + + +def _mark_opencli_available(monkeypatch) -> None: + monkeypatch.setattr( + publish_service, + "_opencli_status", + lambda: { + "available": True, + "message": "测试 opencli 可用", + "restart_command": ".\\scripts\\start_docker_opencli.ps1", + }, + ) + + +def test_explicit_scheduled_opencli_job_can_start_now(tmp_path, monkeypatch): + job_id = _insert_job( + tmp_path, + platform="douyin", + publish_mode="opencli_publish", + status="SCHEDULED", + scheduled_at=_iso(3600), + ) + background_tasks = FakeBackgroundTasks() + _mark_opencli_available(monkeypatch) + + result = publish_service.start_opencli_send_batch( + PublishSendStart(job_ids=[job_id]), + background_tasks=background_tasks, + ) + + assert result["status"] == "started" + assert background_tasks.tasks[0][1] == ([job_id],) + assert "立即发送" in result["message"] + + +def test_bulk_send_without_selection_does_not_start_future_scheduled_opencli_job(tmp_path, monkeypatch): + _insert_job( + tmp_path, + platform="douyin", + publish_mode="opencli_publish", + status="SCHEDULED", + scheduled_at=_iso(3600), + ) + background_tasks = FakeBackgroundTasks() + _mark_opencli_available(monkeypatch) + + result = publish_service.start_opencli_send_batch( + PublishSendStart(job_ids=[]), + background_tasks=background_tasks, + ) + + assert result["status"] == "empty" + assert background_tasks.tasks == [] + assert "已排期任务不会" in result["message"] + + +def test_bulk_send_still_starts_waiting_and_failed_opencli_jobs(tmp_path, monkeypatch): + waiting_job_id = _insert_job( + tmp_path, + platform="douyin", + publish_mode="opencli_publish", + status="WAITING", + scheduled_at="", + ) + failed_job_id = _insert_job( + tmp_path, + platform="douyin", + publish_mode="opencli_publish", + status="FAILED", + scheduled_at="", + ) + background_tasks = FakeBackgroundTasks() + _mark_opencli_available(monkeypatch) + + result = publish_service.start_opencli_send_batch( + PublishSendStart(job_ids=[]), + background_tasks=background_tasks, + ) + + assert result["status"] == "started" + assert set(background_tasks.tasks[0][1][0]) == {waiting_job_id, failed_job_id} + + def test_future_scheduled_job_is_not_published(tmp_path): job_id = _insert_job(tmp_path, scheduled_at=_iso(3600)) result = PublishScheduler(interval_seconds=1).run_once() From 31af526823f25baa19a00089aad71f2e8a00b2e8 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 21:25:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=8F=91?= =?UTF-8?q?=E9=80=81=E4=B8=AD=E5=BF=83=E6=97=A5=E5=8E=86=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=8E=92=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_LOG.md | 7 + NEXT_STEPS.md | 10 ++ app/static/css/styles.css | 161 ++++++++++++++++++++++ app/static/js/app.js | 276 +++++++++++++++++++++++++++++++++++++ app/templates/base.html | 4 +- app/templates/publish.html | 24 +++- docs/UI_REFERENCE.md | 8 ++ 7 files changed, 486 insertions(+), 4 deletions(-) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index bec5300..9fa4100 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,12 @@ # Development Log +## 2026-07-02 发送中心日历拖拽排期 +- 发送中心“批量发布计划”面板新增 7 天发布日历,按每日开始时间、结束时间和间隔小时生成时间格,可用上一周 / 本周 / 下一周切换查看。 +- 支持把下方待发送任务卡片拖到日历时间格,前端会调用现有 `PATCH /api/publish/jobs/schedule-batch` 接口为单条任务设置发布时间,不新增数据库结构。 +- 日历会读取页面内已有计划时间,把已排期任务显示为平台色块;点击日历色块会滚动定位到对应任务卡。 +- 已发布任务的单条发送按钮文案改为“已发布”,并带状态说明,减少用户误以为发送按钮损坏。 +- 静态资源版本更新为 `20260702-publish-calendar-v2`,确保 Docker 页面刷新后加载新的 CSS / JS。 + ## 2026-07-01 抖音已排期任务手动立即发送修复 - 修复发送中心“发送此条”对 `SCHEDULED` 抖音任务没有启动 opencli 的问题:用户显式点击单条发送或勾选后批量发送时,已排期任务会按本次手动操作立即进入 opencli 发送队列。 - 保留“开始发送全部”的安全边界:未勾选任何任务时,只会发送 `WAITING` / `FAILED` 的 opencli 任务,不会把未来排期任务全部提前发送。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index f50d75d..0ad1fee 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,15 @@ # Next Steps +## 2026-07-02 发送中心日历排期怎么测试 +1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。 +2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确认页面加载 `20260702-publish-calendar-v2` 版资源。 +3. 在“批量发布计划”面板里查看 7 天发布日历;可以点“上一周 / 本周 / 下一周”切换时间范围。 +4. 修改“间隔小时、每日开始、每日结束”后,日历的时间格会按新的时段刷新。 +5. 在下面的视频任务卡片中,拖动一条“等待处理 / 待发送 / 发送失败”的任务到日历某天某个时间格。 +6. 正常情况:页面会提示正在安排任务,随后刷新;该任务的“计划”时间应变成刚才拖入的日期和时间。 +7. 已发布任务不能拖拽排期,也不会显示“发送此条”;按钮会显示“已发布”,避免误点重复发送。 +8. 如果需要一次性批量排期,原来的勾选任务 + “应用发布计划”仍然保留。 + ## 2026-07-01 抖音发送按钮修复后怎么测试 1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。 2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确保加载 `20260701-douyin-scheduled-send` 版脚本。 diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 8c2c0c4..bcbd408 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -3869,6 +3869,162 @@ body.transcript-drawer-open .main-panel { margin: 0; } +.publish-calendar-shell { + display: grid; + gap: 12px; + overflow-x: auto; + padding: 14px; + border: 1px solid rgba(37, 111, 255, 0.14); + border-radius: 8px; + background: linear-gradient(180deg, rgba(246, 249, 255, 0.86), rgba(255, 255, 255, 0.92)); +} + +.publish-calendar-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 980px; +} + +.publish-calendar-toolbar h3 { + margin: 4px 0 0; + font-size: 18px; +} + +.publish-calendar-grid { + --calendar-slot-rows: 5; + display: grid; + grid-template-columns: 74px repeat(7, minmax(132px, 1fr)); + grid-template-rows: 58px repeat(var(--calendar-slot-rows), minmax(92px, auto)); + min-width: 980px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-solid); +} + +.publish-calendar-corner, +.publish-calendar-day, +.publish-calendar-time, +.publish-calendar-slot { + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.publish-calendar-corner, +.publish-calendar-day, +.publish-calendar-time { + background: #f6f9ff; +} + +.publish-calendar-corner, +.publish-calendar-time { + display: grid; + place-items: center; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.publish-calendar-day { + display: grid; + align-content: center; + gap: 4px; + padding: 10px; +} + +.publish-calendar-day strong { + font-size: 14px; +} + +.publish-calendar-day span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.publish-calendar-slot { + position: relative; + display: grid; + align-content: start; + gap: 6px; + min-height: 92px; + padding: 8px; + background: rgba(255, 255, 255, 0.8); + transition: background 0.18s ease, box-shadow 0.18s ease; +} + +.publish-calendar-slot.is-drop-target { + background: rgba(37, 111, 255, 0.08); + box-shadow: inset 0 0 0 2px rgba(37, 111, 255, 0.34); +} + +.publish-calendar-empty { + color: #9aa7b8; + font-size: 12px; + font-weight: 700; +} + +.publish-calendar-chip { + display: grid; + gap: 3px; + width: 100%; + padding: 7px 8px; + border: 1px solid rgba(37, 111, 255, 0.18); + border-radius: 8px; + background: #eef5ff; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.publish-calendar-chip span { + color: var(--primary); + font-size: 11px; + font-weight: 800; +} + +.publish-calendar-chip strong { + display: -webkit-box; + overflow: hidden; + font-size: 12px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.publish-calendar-chip.tone-bilibili { + border-color: rgba(34, 160, 107, 0.22); + background: #ecfbf5; +} + +.publish-calendar-chip.tone-bilibili span { + color: #14825a; +} + +.publish-calendar-chip.tone-default { + border-color: rgba(111, 125, 148, 0.22); + background: #f4f7fb; +} + +.publish-calendar-chip.tone-default span { + color: #637083; +} + +.send-card.can-drag-schedule { + cursor: grab; +} + +.send-card.can-drag-schedule:active { + cursor: grabbing; +} + +.send-card.is-dragging { + opacity: 0.72; + box-shadow: 0 22px 46px rgba(37, 111, 255, 0.18); +} + .schedule-time-note { color: var(--muted); font-size: 12px; @@ -3883,6 +4039,11 @@ body.transcript-drawer-open .main-panel { .publish-schedule-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .publish-calendar-toolbar, + .publish-calendar-grid { + min-width: 900px; + } } @media (max-width: 640px) { diff --git a/app/static/js/app.js b/app/static/js/app.js index f972f58..4650865 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2235,6 +2235,10 @@ const sendPreviewPanel = document.querySelector("[data-send-preview-panel]"); const publishScheduleForm = document.querySelector("[data-publish-schedule-form]"); const publishScheduleResult = document.querySelector("[data-publish-schedule-result]"); const scheduleSelectedCount = document.querySelector("[data-schedule-selected-count]"); +const publishCalendarGrid = document.querySelector("[data-publish-calendar-grid]"); +const publishCalendarRange = document.querySelector("[data-publish-calendar-range]"); +let publishCalendarStartDate = null; +let draggedScheduleJobId = ""; function setSendCenterMessage(message, tone = "info") { if (!sendCenterMessage) return; @@ -2288,6 +2292,218 @@ function setScheduleResult(message, tone = "info") { publishScheduleResult.classList.toggle("error-text", tone === "error"); } +function padDatePart(value) { + return String(value).padStart(2, "0"); +} + +function localDateKey(date) { + return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; +} + +function localTimeKey(date) { + return `${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`; +} + +function calendarSlotKey(date) { + return `${localDateKey(date)} ${localTimeKey(date)}`; +} + +function localDayStart(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function localWeekStart(date) { + const start = localDayStart(date); + const offset = (start.getDay() + 6) % 7; + start.setDate(start.getDate() - offset); + return start; +} + +function addCalendarDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function parseScheduleClock(value, fallbackHour, fallbackMinute = 0) { + const match = String(value || "").match(/^(\d{1,2}):(\d{2})$/); + if (!match) return { hour: fallbackHour, minute: fallbackMinute }; + const hour = Math.min(23, Math.max(0, Number(match[1]))); + const minute = Math.min(59, Math.max(0, Number(match[2]))); + return { hour, minute }; +} + +function scheduleIntervalHours() { + const value = Number(publishScheduleForm?.elements.interval_hours?.value || 3); + return Number.isFinite(value) ? Math.min(168, Math.max(1, value)) : 3; +} + +function scheduleSlotTimes() { + const start = parseScheduleClock(publishScheduleForm?.elements.daily_start_time?.value, 9); + const end = parseScheduleClock(publishScheduleForm?.elements.daily_end_time?.value, 21); + const startMinutes = start.hour * 60 + start.minute; + const endMinutes = end.hour * 60 + end.minute; + if (endMinutes < startMinutes) { + return [{ hour: start.hour, minute: start.minute }]; + } + const intervalMinutes = scheduleIntervalHours() * 60; + const slots = []; + for (let cursor = startMinutes; cursor <= endMinutes; cursor += intervalMinutes) { + slots.push({ hour: Math.floor(cursor / 60), minute: cursor % 60 }); + } + return slots.length ? slots : [{ hour: start.hour, minute: start.minute }]; +} + +function scheduleCardInfo(card) { + const form = card?.querySelector("[data-send-job-form]"); + const jobId = form?.dataset.jobId || ""; + const title = form?.elements.title?.value || card?.querySelector(".send-card-header h3")?.textContent || "未命名任务"; + const platformLabel = card?.querySelector(".status-pill")?.textContent || form?.dataset.platform || "任务"; + const status = (card?.dataset.status || "").toUpperCase(); + return { card, form, jobId, title: title.trim(), platformLabel: platformLabel.trim(), status }; +} + +function scheduledEntriesBySlot() { + const entries = new Map(); + document.querySelectorAll("[data-send-card]").forEach((card) => { + const info = scheduleCardInfo(card); + if (!info.jobId) return; + const scheduledValue = card.querySelector("[data-publish-scheduled-at]")?.dataset.publishScheduledAt || ""; + if (!scheduledValue) return; + const scheduledDate = new Date(scheduledValue); + if (Number.isNaN(scheduledDate.getTime())) return; + const key = calendarSlotKey(scheduledDate); + if (!entries.has(key)) entries.set(key, []); + entries.get(key).push({ ...info, scheduledDate }); + }); + return entries; +} + +function calendarSeedDate() { + const today = localDayStart(new Date()); + const scheduledDates = Array.from(document.querySelectorAll("[data-send-card] [data-publish-scheduled-at]")) + .map((node) => new Date(node.dataset.publishScheduledAt || "")) + .filter((date) => !Number.isNaN(date.getTime())) + .sort((a, b) => a.getTime() - b.getTime()); + const upcoming = scheduledDates.find((date) => date >= today); + return upcoming || today; +} + +function formatCalendarRange(startDate) { + const endDate = addCalendarDays(startDate, 6); + const format = (date) => `${date.getFullYear()}/${padDatePart(date.getMonth() + 1)}/${padDatePart(date.getDate())}`; + return `${format(startDate)} - ${format(endDate)}`; +} + +function platformCalendarTone(platformLabel) { + if (platformLabel.includes("抖音")) return "douyin"; + if (platformLabel.includes("B站")) return "bilibili"; + return "default"; +} + +function renderPublishCalendar() { + if (!publishCalendarGrid) return; + if (!publishCalendarStartDate) { + publishCalendarStartDate = localWeekStart(calendarSeedDate()); + } + + const slotTimes = scheduleSlotTimes(); + const entries = scheduledEntriesBySlot(); + publishCalendarGrid.replaceChildren(); + publishCalendarGrid.style.setProperty("--calendar-slot-rows", String(slotTimes.length)); + if (publishCalendarRange) { + publishCalendarRange.textContent = formatCalendarRange(publishCalendarStartDate); + } + + const corner = document.createElement("div"); + corner.className = "publish-calendar-corner"; + corner.textContent = "时间"; + publishCalendarGrid.append(corner); + + for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { + const dayDate = addCalendarDays(publishCalendarStartDate, dayIndex); + const heading = document.createElement("div"); + heading.className = "publish-calendar-day"; + heading.innerHTML = `${dayDate.toLocaleDateString([], { weekday: "short" })}${padDatePart( + dayDate.getMonth() + 1 + )}/${padDatePart(dayDate.getDate())}`; + publishCalendarGrid.append(heading); + } + + slotTimes.forEach((slotTime) => { + const timeCell = document.createElement("div"); + timeCell.className = "publish-calendar-time"; + timeCell.textContent = `${padDatePart(slotTime.hour)}:${padDatePart(slotTime.minute)}`; + publishCalendarGrid.append(timeCell); + + for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { + const slotDate = addCalendarDays(publishCalendarStartDate, dayIndex); + slotDate.setHours(slotTime.hour, slotTime.minute, 0, 0); + const slotCell = document.createElement("div"); + slotCell.className = "publish-calendar-slot"; + slotCell.dataset.publishCalendarSlot = "true"; + slotCell.dataset.slotStart = slotDate.toISOString(); + const slotEntries = entries.get(calendarSlotKey(slotDate)) || []; + if (!slotEntries.length) { + const empty = document.createElement("span"); + empty.className = "publish-calendar-empty"; + empty.textContent = "空档"; + slotCell.append(empty); + } + slotEntries.forEach((entry) => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `publish-calendar-chip tone-${platformCalendarTone(entry.platformLabel)}`; + chip.dataset.jobId = entry.jobId; + chip.innerHTML = `${entry.platformLabel}${entry.title}`; + chip.addEventListener("click", () => { + entry.card?.scrollIntoView({ block: "center", behavior: "smooth" }); + entry.card?.classList.add("is-previewing"); + window.setTimeout(() => entry.card?.classList.remove("is-previewing"), 1600); + }); + slotCell.append(chip); + }); + publishCalendarGrid.append(slotCell); + } + }); +} + +async function scheduleJobAtSlot(jobId, slotStart) { + const slotDate = new Date(slotStart); + if (!jobId || Number.isNaN(slotDate.getTime())) { + setScheduleResult("没有识别到要排期的任务或时间。", "error"); + return; + } + const card = Array.from(document.querySelectorAll("[data-send-card]")).find( + (item) => item.querySelector("[data-send-job-form]")?.dataset.jobId === jobId + ); + const info = scheduleCardInfo(card); + setScheduleResult(`正在安排:${info.title} -> ${slotDate.toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`); + + try { + const response = await fetch("/api/publish/jobs/schedule-batch", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + job_ids: [jobId], + action: "apply", + start_at: slotDate.toISOString(), + interval_hours: scheduleIntervalHours(), + daily_start_time: String(publishScheduleForm?.elements.daily_start_time?.value || "09:00"), + daily_end_time: String(publishScheduleForm?.elements.daily_end_time?.value || "21:00"), + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || data.message || "排期失败"); + } + setScheduleResult(data.message || "已更新发布时间。"); + reloadSendCenter(700); + } catch (error) { + setScheduleResult(`排期失败:${error.message}`, "error"); + } +} + function formatScheduledTimes() { document.querySelectorAll("[data-publish-scheduled-at]").forEach((node) => { const value = node.dataset.publishScheduledAt || ""; @@ -2393,6 +2609,66 @@ document.querySelector("[data-clear-batch-schedule]")?.addEventListener("click", formatScheduledTimes(); updateScheduleSelectedCount(); +renderPublishCalendar(); + +document.querySelectorAll("[data-schedule-draggable]").forEach((card) => { + const info = scheduleCardInfo(card); + if (!info.jobId) return; + card.addEventListener("dragstart", (event) => { + draggedScheduleJobId = info.jobId; + card.classList.add("is-dragging"); + event.dataTransfer?.setData("text/plain", info.jobId); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + }); + card.addEventListener("dragend", () => { + draggedScheduleJobId = ""; + card.classList.remove("is-dragging"); + document.querySelectorAll(".publish-calendar-slot.is-drop-target").forEach((slot) => { + slot.classList.remove("is-drop-target"); + }); + }); +}); + +publishCalendarGrid?.addEventListener("dragover", (event) => { + const slot = event.target.closest("[data-publish-calendar-slot]"); + if (!slot) return; + event.preventDefault(); + slot.classList.add("is-drop-target"); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; +}); + +publishCalendarGrid?.addEventListener("dragleave", (event) => { + const slot = event.target.closest("[data-publish-calendar-slot]"); + if (slot && !slot.contains(event.relatedTarget)) { + slot.classList.remove("is-drop-target"); + } +}); + +publishCalendarGrid?.addEventListener("drop", (event) => { + const slot = event.target.closest("[data-publish-calendar-slot]"); + if (!slot) return; + event.preventDefault(); + slot.classList.remove("is-drop-target"); + const jobId = event.dataTransfer?.getData("text/plain") || draggedScheduleJobId; + scheduleJobAtSlot(jobId, slot.dataset.slotStart || ""); +}); + +document.querySelectorAll("[data-publish-calendar-shift]").forEach((button) => { + button.addEventListener("click", () => { + const days = Number(button.dataset.publishCalendarShift || 0); + publishCalendarStartDate = addCalendarDays(publishCalendarStartDate || localWeekStart(new Date()), days); + renderPublishCalendar(); + }); +}); + +document.querySelector("[data-publish-calendar-today]")?.addEventListener("click", () => { + publishCalendarStartDate = localWeekStart(new Date()); + renderPublishCalendar(); +}); + +["interval_hours", "daily_start_time", "daily_end_time"].forEach((fieldName) => { + publishScheduleForm?.elements[fieldName]?.addEventListener("change", renderPublishCalendar); +}); function sendJobPayload(form) { const formData = new FormData(form); diff --git a/app/templates/base.html b/app/templates/base.html index 464a98d..7cc0eaf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,7 +8,7 @@ - + {% block extra_head %}{% endblock %} @@ -61,6 +61,6 @@ {% block extra_scripts %}{% endblock %} - + diff --git a/app/templates/publish.html b/app/templates/publish.html index 62e028e..cfba1e2 100644 --- a/app/templates/publish.html +++ b/app/templates/publish.html @@ -85,6 +85,20 @@

批量发布计划

+
+
+
+

Calendar Board

+

本周发布日历

+
+
+ + + +
+
+
+
@@ -115,7 +129,8 @@

已完成切片自动入队

{% for item in send_queue_items %} -
+ {% set can_schedule_item = item.job_id and item.status in ["WAITING", "SCHEDULED", "FAILED", "ready", "failed"] %} +
@@ -216,7 +231,12 @@

{{ item.title }}

- +
diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index 70c7a61..138b9ff 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,13 @@ # UI 参考说明 +## 2026-07-02 更新:发送中心日历拖拽排期 +- 发送中心“批量发布计划”面板新增 7 天日历时间表,沿用 Apple 风格浅色卡片、蓝色强调色、8px 圆角和轻量边框。 +- 日历按照“每日开始 / 每日结束 / 间隔小时”生成时间格,支持上一周、本周、下一周切换;在桌面宽度下横向展示,窄屏保持横向滚动,避免挤压文字。 +- 下方发送任务卡片在 `WAITING`、`SCHEDULED`、`FAILED` 等可排期状态下支持拖拽;拖入日历时间格后为该任务设置发布时间。 +- 已排期任务会在日历格中显示平台色块,点击色块会定位到对应任务卡片,方便核对视频、标题和平台。 +- 已发布任务的按钮文案改为“已发布”,并用禁用状态和提示说明不能重复发送,避免用户把灰色按钮理解为页面失灵。 +- 不新增 React / Vue,不改变右侧投稿预览和现有发送队列结构;批量表单仍保留,用于一次性给多条任务排期。 + ## 2026-06-25 更新:全自动入口精简与发送中心批量排期 - 新建任务页的全自动模式只保留“新建后自动跑完整流水线”开关;自动切片数量直接使用上方候选片段数量,时长上限直接使用单条切片最长。 - 新建页不再展示自动切片数量、片段时长范围、发布计划、间隔小时、起始时间和固定时段,主按钮改为“创建任务 / 创建并自动处理”。 From 4c8a7f9b8d5d2bdbaf4cc6e6a8dfddfbe40ae386 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 22:08:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E5=8F=91?= =?UTF-8?q?=E9=80=81=E4=B8=AD=E5=BF=83=E6=94=B9=E4=B8=BA=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E8=A1=A8=E4=BB=BB=E5=8A=A1=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_LOG.md | 12 +- NEXT_STEPS.md | 16 +- app/models/task.py | 14 ++ app/routers/publish.py | 14 ++ app/services/publish_scheduler.py | 83 ++++++ app/services/publish_service.py | 70 ++++++ app/static/css/styles.css | 275 ++++++++++++++------ app/static/js/app.js | 291 ++++++++++++---------- app/templates/base.html | 4 +- app/templates/publish.html | 257 +++++++++++-------- docs/UI_REFERENCE.md | 13 +- scripts/test_send_center_opencli_queue.py | 6 + tests/test_publish_scheduler.py | 24 +- 13 files changed, 740 insertions(+), 339 deletions(-) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 9fa4100..8149098 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,11 +1,11 @@ # Development Log -## 2026-07-02 发送中心日历拖拽排期 -- 发送中心“批量发布计划”面板新增 7 天发布日历,按每日开始时间、结束时间和间隔小时生成时间格,可用上一周 / 本周 / 下一周切换查看。 -- 支持把下方待发送任务卡片拖到日历时间格,前端会调用现有 `PATCH /api/publish/jobs/schedule-batch` 接口为单条任务设置发布时间,不新增数据库结构。 -- 日历会读取页面内已有计划时间,把已排期任务显示为平台色块;点击日历色块会滚动定位到对应任务卡。 -- 已发布任务的单条发送按钮文案改为“已发布”,并带状态说明,减少用户误以为发送按钮损坏。 -- 静态资源版本更新为 `20260702-publish-calendar-v2`,确保 Docker 页面刷新后加载新的 CSS / JS。 +## 2026-07-02 发送中心日期表与任务卡片管理 +- 发送中心排期区改为 7 天“发送日期表”,只按日期列展示,不再生成时间轴 / 小时格;支持上一周、本周、下一周切换。 +- 待发送队列改为按创建任务分组,每张大卡片代表一个创建任务,卡片内保留抖音 / B站和各条切片的发送内容编辑、封面帧、保存和发送操作。 +- 任务卡顶部新增“拖到日期”手柄,只拖动任务组本身,避免视频、输入框和按钮干扰拖拽;同时保留选择日期后点击“安排到这一天”的兜底操作。 +- 新增 `PATCH /api/publish/jobs/schedule-date`,用于把任务组下可排期条目安排到指定日期;不新增数据库字段,仍写入现有 `publish_jobs.scheduled_at`。 +- 静态资源版本更新为 `20260702-publish-date-board`,确保 Docker 页面刷新后加载新的 CSS / JS。 ## 2026-07-01 抖音已排期任务手动立即发送修复 - 修复发送中心“发送此条”对 `SCHEDULED` 抖音任务没有启动 opencli 的问题:用户显式点击单条发送或勾选后批量发送时,已排期任务会按本次手动操作立即进入 opencli 发送队列。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 0ad1fee..e0c191d 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,14 +1,14 @@ # Next Steps -## 2026-07-02 发送中心日历排期怎么测试 +## 2026-07-02 发送中心日期表怎么测试 1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。 -2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确认页面加载 `20260702-publish-calendar-v2` 版资源。 -3. 在“批量发布计划”面板里查看 7 天发布日历;可以点“上一周 / 本周 / 下一周”切换时间范围。 -4. 修改“间隔小时、每日开始、每日结束”后,日历的时间格会按新的时段刷新。 -5. 在下面的视频任务卡片中,拖动一条“等待处理 / 待发送 / 发送失败”的任务到日历某天某个时间格。 -6. 正常情况:页面会提示正在安排任务,随后刷新;该任务的“计划”时间应变成刚才拖入的日期和时间。 -7. 已发布任务不能拖拽排期,也不会显示“发送此条”;按钮会显示“已发布”,避免误点重复发送。 -8. 如果需要一次性批量排期,原来的勾选任务 + “应用发布计划”仍然保留。 +2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确认页面加载 `20260702-publish-date-board` 版资源。 +3. 在“发送日期表”里查看 7 天日期列;可以点“上一周 / 本周 / 下一周”切换时间范围。 +4. 下方“待发送队列”应该按创建任务显示为大卡片,每张任务卡里再展示抖音 / B站和切片发送条目。 +5. 拖动任务卡顶部的“拖到日期”按钮到某一天;不要拖视频、输入框或发送按钮。 +6. 正常情况:页面会提示正在安排任务,随后刷新;该任务卡里的可排期条目应显示对应日期的计划时间。 +7. 如果拖不动,在任务卡顶部选择“兜底日期”,再点“安排到这一天”。 +8. 如果需要一次性批量排期,仍可勾选具体发送条目后使用“应用发布计划”;已发布任务不能重复发送。 ## 2026-07-01 抖音发送按钮修复后怎么测试 1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。 diff --git a/app/models/task.py b/app/models/task.py index e71bb48..cbf9b4c 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -182,6 +182,20 @@ def validate_schedule_job_ids(cls, value: list[str]) -> list[str]: return normalized +class PublishDateScheduleUpdate(BaseModel): + job_ids: list[str] = Field(default_factory=list) + target_date: str = Field(..., min_length=10, max_length=10) + start_time: str = Field(default="09:00", min_length=5, max_length=5) + interval_hours: int = Field(default=3, ge=1, le=168) + + @validator("job_ids") + def validate_date_schedule_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 4d07d4c..01aa332 100644 --- a/app/routers/publish.py +++ b/app/routers/publish.py @@ -9,6 +9,7 @@ PublishBatchScheduleUpdate, PublishCoverCreate, PublishCoverFrameBatchCreate, + PublishDateScheduleUpdate, PublishJobContentUpdate, PublishJobCreate, PublishJobScheduleUpdate, @@ -234,6 +235,19 @@ async def update_publish_jobs_schedule_batch(payload: PublishBatchScheduleUpdate raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.patch("/jobs/schedule-date") +async def update_publish_jobs_schedule_date(payload: PublishDateScheduleUpdate) -> dict: + try: + return PublishScheduler().update_date_schedule( + payload.job_ids, + target_date=payload.target_date, + start_time=payload.start_time, + interval_hours=payload.interval_hours, + ) + 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/services/publish_scheduler.py b/app/services/publish_scheduler.py index c9b8589..c43e16e 100644 --- a/app/services/publish_scheduler.py +++ b/app/services/publish_scheduler.py @@ -80,6 +80,28 @@ def build_batch_schedule_times( return scheduled +def build_date_schedule_times( + count: int, + *, + target_date: str, + start_time: str, + interval_hours: int, +) -> list[str]: + if count <= 0: + return [] + + clock = parse_clock(start_time, "当天开始时间") + try: + cursor = datetime.fromisoformat(f"{target_date.strip()}T{clock.isoformat(timespec='minutes')}") + except ValueError as exc: + raise ValueError("目标日期格式无效,请使用 YYYY-MM-DD") from exc + if cursor.tzinfo is None: + cursor = cursor.astimezone() + + interval = timedelta(hours=max(1, int(interval_hours))) + return [(cursor + interval * index).isoformat(timespec="seconds") for index in range(count)] + + def _row_to_dict(row) -> dict[str, Any] | None: return dict(row) if row else None @@ -372,6 +394,67 @@ def update_batch_schedule( "jobs": [get_publish_job_raw(job_id) for job_id in normalized_ids], } + def update_date_schedule( + self, + job_ids: list[str], + *, + target_date: str, + start_time: str = "09:00", + interval_hours: int = 3, + ) -> dict[str, Any]: + normalized_ids = list(dict.fromkeys(str(job_id).strip() for job_id in job_ids if str(job_id).strip())) + if not normalized_ids: + raise ValueError("至少选择一条发布任务") + + placeholders = ", ".join("?" for _ in normalized_ids) + with get_connection() as connection: + rows = connection.execute( + f"SELECT * FROM publish_jobs WHERE id IN ({placeholders})", + normalized_ids, + ).fetchall() + jobs_by_id = {row["id"]: dict(row) for row in rows} + missing_ids = [job_id for job_id in normalized_ids if job_id not in jobs_by_id] + if missing_ids: + raise ValueError(f"有 {len(missing_ids)} 条发布任务不存在") + blocked = [ + job_id + for job_id in normalized_ids + if str(jobs_by_id[job_id].get("status") or "").upper() in {"PUBLISHED", "CANCELLED"} + ] + if blocked: + raise ValueError("已发布或已取消的任务不能修改排期") + + schedule_times = build_date_schedule_times( + len(normalized_ids), + target_date=target_date, + start_time=start_time, + interval_hours=interval_hours, + ) + + now = now_iso() + with get_connection() as connection: + for job_id, scheduled_at in zip(normalized_ids, schedule_times, strict=True): + current_status = str(jobs_by_id[job_id].get("status") or "").upper() + next_status = "NEED_REVIEW" if current_status == "NEED_REVIEW" else "SCHEDULED" + connection.execute( + """ + UPDATE publish_jobs + SET scheduled_at = ?, status = ?, updated_at = ?, + error_code = '', error_message = '', last_error = '' + WHERE id = ? + """, + (scheduled_at, next_status, now, job_id), + ) + connection.commit() + + return { + "status": "ok", + "action": "schedule_date", + "updated_count": len(normalized_ids), + "message": f"已把 {len(normalized_ids)} 条任务安排到 {target_date}。", + "jobs": [get_publish_job_raw(job_id) for job_id in normalized_ids], + } + def _set_schedule_to_now(self, job_id: str) -> None: now = now_iso() with get_connection() as connection: diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 5c3100f..85e1d1f 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -554,6 +554,74 @@ def _normalize_job(row) -> dict: return job +def _send_item_can_schedule(item: dict) -> bool: + status = str(item.get("status") or "").upper() + return bool(item.get("job_id")) and status in { + PUBLISH_STATUS_WAITING, + PUBLISH_STATUS_SCHEDULED, + PUBLISH_STATUS_FAILED, + "READY", + } + + +def _build_send_task_groups(queue_items: list[dict]) -> list[dict]: + groups: dict[str, dict] = {} + for item in queue_items: + task_id = item.get("task_id") or "unknown-task" + group = groups.get(task_id) + if not group: + group = { + "task_id": task_id, + "task_name": item.get("task_name") or "未命名任务", + "items": [], + "job_ids": [], + "schedule_job_ids": [], + "platform_labels": [], + "status_labels": [], + "output_clip_ids": [], + "scheduled_count": 0, + "published_count": 0, + "failed_count": 0, + "cover_media_url": item.get("cover_media_url") or "", + "video_media_url": item.get("video_media_url") or "", + } + groups[task_id] = group + + group["items"].append(item) + if item.get("job_id"): + group["job_ids"].append(item["job_id"]) + if _send_item_can_schedule(item): + group["schedule_job_ids"].append(item["job_id"]) + if item.get("platform_label") and item["platform_label"] not in group["platform_labels"]: + group["platform_labels"].append(item["platform_label"]) + if item.get("status_label") and item["status_label"] not in group["status_labels"]: + group["status_labels"].append(item["status_label"]) + if item.get("output_clip_id") and item["output_clip_id"] not in group["output_clip_ids"]: + group["output_clip_ids"].append(item["output_clip_id"]) + if not group.get("cover_media_url") and item.get("cover_media_url"): + group["cover_media_url"] = item["cover_media_url"] + if not group.get("video_media_url") and item.get("video_media_url"): + group["video_media_url"] = item["video_media_url"] + + status = str(item.get("status") or "").upper() + if status == PUBLISH_STATUS_SCHEDULED: + group["scheduled_count"] += 1 + if status == PUBLISH_STATUS_PUBLISHED: + group["published_count"] += 1 + if status == PUBLISH_STATUS_FAILED: + group["failed_count"] += 1 + + for group in groups.values(): + group["item_count"] = len(group["items"]) + group["clip_count"] = len(group["output_clip_ids"]) + group["can_schedule"] = bool(group["schedule_job_ids"]) + group["platform_summary"] = " / ".join(group["platform_labels"]) or "未入队" + group["status_summary"] = " / ".join(group["status_labels"]) or "待入队" + group["schedule_job_ids_csv"] = ",".join(group["schedule_job_ids"]) + + return list(groups.values()) + + def list_platform_configs() -> list[dict]: with get_connection() as connection: rows = connection.execute( @@ -2796,6 +2864,7 @@ def get_publish_center_context() -> dict: } ) + task_groups = _build_send_task_groups(queue_items) jobs = list_publish_jobs(limit=200) jobs_by_platform = { platform: [job for job in jobs if job["platform"] == platform] @@ -2810,6 +2879,7 @@ def get_publish_center_context() -> dict: return { "publish_items": publish_items, "send_queue_items": queue_items, + "send_task_groups": task_groups, "publish_jobs": jobs, "jobs_by_platform": jobs_by_platform, "platforms": [{"id": platform, "label": label} for platform, label in PLATFORM_LABELS.items()], diff --git a/app/static/css/styles.css b/app/static/css/styles.css index bcbd408..ebeed71 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -3336,23 +3336,132 @@ body.transcript-drawer-open .main-panel { gap: 16px; } -.send-card { +.send-task-card { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-solid); + box-shadow: var(--shadow-soft); +} + +.send-task-card.is-previewing, +.send-task-card.is-dragging { + border-color: rgba(37, 111, 255, 0.34); + box-shadow: 0 18px 38px rgba(37, 111, 255, 0.12); +} + +.send-task-card.is-dragging { + opacity: 0.76; +} + +.send-task-card.is-hidden { + display: none; +} + +.send-task-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.send-task-summary { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 12px; + min-width: 0; +} + +.send-task-thumb { + display: grid; + place-items: center; + width: 76px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #eef4fb; + aspect-ratio: 1 / 1; + color: var(--muted); + font-size: 18px; + font-weight: 900; +} + +.send-task-thumb img, +.send-task-thumb video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.send-task-summary h3 { + margin: 8px 0 4px; + font-size: 19px; + overflow-wrap: anywhere; +} + +.send-task-summary p { + margin: 0; + color: var(--muted); + font-size: 13px; + overflow-wrap: anywhere; +} + +.send-task-schedule-controls { + display: flex; + align-items: end; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.send-task-schedule-controls label { + display: grid; + gap: 5px; + min-width: 142px; +} + +.send-task-schedule-controls label span { + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.schedule-drag-handle[draggable="true"] { + cursor: grab; +} + +.schedule-drag-handle[draggable="true"]:active { + cursor: grabbing; +} + +.send-task-items { + display: grid; + gap: 12px; +} + +.send-card, +.send-job-item { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 16px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; - background: var(--surface-solid); - box-shadow: var(--shadow-soft); + background: rgba(246, 249, 255, 0.58); } -.send-card.is-previewing { +.send-card.is-previewing, +.send-job-item.is-previewing { border-color: rgba(37, 111, 255, 0.34); box-shadow: 0 18px 38px rgba(37, 111, 255, 0.12); } -.send-card.is-hidden { +.send-card.is-hidden, +.send-job-item.is-hidden { display: none; } @@ -3663,6 +3772,7 @@ body.transcript-drawer-open .main-panel { .review-metrics, .send-center-grid, .send-card, + .send-job-item, .send-fields, .send-platform-options, .publish-backend-grid, @@ -3682,6 +3792,15 @@ body.transcript-drawer-open .main-panel { grid-column: span 1; } + .send-task-card-header { + align-items: stretch; + flex-direction: column; + } + + .send-task-schedule-controls { + justify-content: flex-start; + } + .prompt-preset-tabs, .ai-analysis-controls, .ai-analysis-result-header, @@ -3892,85 +4011,72 @@ body.transcript-drawer-open .main-panel { font-size: 18px; } -.publish-calendar-grid { - --calendar-slot-rows: 5; +.publish-date-board { display: grid; - grid-template-columns: 74px repeat(7, minmax(132px, 1fr)); - grid-template-rows: 58px repeat(var(--calendar-slot-rows), minmax(92px, auto)); + grid-template-columns: repeat(7, minmax(154px, 1fr)); + gap: 10px; min-width: 980px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface-solid); } -.publish-calendar-corner, -.publish-calendar-day, -.publish-calendar-time, -.publish-calendar-slot { - border-right: 1px solid var(--line); - border-bottom: 1px solid var(--line); -} - -.publish-calendar-corner, -.publish-calendar-day, -.publish-calendar-time { - background: #f6f9ff; +.publish-date-column { + display: grid; + align-content: start; + gap: 10px; + min-height: 260px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.82); + transition: background 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; } -.publish-calendar-corner, -.publish-calendar-time { - display: grid; - place-items: center; - color: var(--muted); - font-size: 12px; - font-weight: 800; +.publish-date-column.is-drop-target { + border-color: rgba(37, 111, 255, 0.34); + background: rgba(37, 111, 255, 0.07); + box-shadow: inset 0 0 0 2px rgba(37, 111, 255, 0.22); } -.publish-calendar-day { +.publish-date-heading { display: grid; - align-content: center; gap: 4px; - padding: 10px; + padding: 8px; + border-radius: 8px; + background: #f6f9ff; } -.publish-calendar-day strong { +.publish-date-heading strong { font-size: 14px; } -.publish-calendar-day span { +.publish-date-heading span, +.publish-date-heading small { color: var(--muted); font-size: 12px; font-weight: 700; } -.publish-calendar-slot { - position: relative; +.publish-date-list { display: grid; align-content: start; - gap: 6px; - min-height: 92px; - padding: 8px; - background: rgba(255, 255, 255, 0.8); - transition: background 0.18s ease, box-shadow 0.18s ease; -} - -.publish-calendar-slot.is-drop-target { - background: rgba(37, 111, 255, 0.08); - box-shadow: inset 0 0 0 2px rgba(37, 111, 255, 0.34); + gap: 8px; } -.publish-calendar-empty { +.publish-date-empty { + display: grid; + place-items: center; + min-height: 90px; + border: 1px dashed rgba(37, 111, 255, 0.22); + border-radius: 8px; color: #9aa7b8; font-size: 12px; - font-weight: 700; + font-weight: 800; } -.publish-calendar-chip { +.publish-date-task { display: grid; - gap: 3px; + gap: 4px; width: 100%; - padding: 7px 8px; + padding: 9px 10px; border: 1px solid rgba(37, 111, 255, 0.18); border-radius: 8px; background: #eef5ff; @@ -3979,13 +4085,13 @@ body.transcript-drawer-open .main-panel { cursor: pointer; } -.publish-calendar-chip span { +.publish-date-task span { color: var(--primary); font-size: 11px; font-weight: 800; } -.publish-calendar-chip strong { +.publish-date-task strong { display: -webkit-box; overflow: hidden; font-size: 12px; @@ -3994,37 +4100,30 @@ body.transcript-drawer-open .main-panel { -webkit-line-clamp: 2; } -.publish-calendar-chip.tone-bilibili { +.publish-date-task small { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.publish-date-task.tone-bilibili { border-color: rgba(34, 160, 107, 0.22); background: #ecfbf5; } -.publish-calendar-chip.tone-bilibili span { +.publish-date-task.tone-bilibili span { color: #14825a; } -.publish-calendar-chip.tone-default { +.publish-date-task.tone-default { border-color: rgba(111, 125, 148, 0.22); background: #f4f7fb; } -.publish-calendar-chip.tone-default span { +.publish-date-task.tone-default span { color: #637083; } -.send-card.can-drag-schedule { - cursor: grab; -} - -.send-card.can-drag-schedule:active { - cursor: grabbing; -} - -.send-card.is-dragging { - opacity: 0.72; - box-shadow: 0 22px 46px rgba(37, 111, 255, 0.18); -} - .schedule-time-note { color: var(--muted); font-size: 12px; @@ -4041,7 +4140,7 @@ body.transcript-drawer-open .main-panel { } .publish-calendar-toolbar, - .publish-calendar-grid { + .publish-date-board { min-width: 900px; } } @@ -4477,7 +4576,9 @@ textarea::placeholder { .ai-analysis-history-card, .subtitle-output-card, .publish-card, -.send-card { +.send-card, +.send-task-card, +.send-job-item { border-color: rgba(31, 45, 71, 0.09); border-radius: 8px; background: rgba(255, 255, 255, 0.78); @@ -4618,6 +4719,7 @@ fieldset input:focus { .review-task-main h3, .send-card-header h3, +.send-task-summary h3, .publish-job-row h3 { color: #111827; } @@ -4798,12 +4900,15 @@ fieldset input:focus { top: 78px; } -.send-card { +.send-card, +.send-job-item { grid-template-columns: minmax(240px, 340px) minmax(0, 1fr); padding: 16px; } -.send-card.is-previewing { +.send-card.is-previewing, +.send-job-item.is-previewing, +.send-task-card.is-previewing { border-color: rgba(31, 111, 255, 0.26); box-shadow: 0 22px 44px rgba(31, 111, 255, 0.13); } @@ -5016,12 +5121,28 @@ td a, .button-row, .review-row-actions, .send-actions, + .send-task-card-header, + .send-task-schedule-controls, .publish-actions, .source-monitor-actions { align-items: stretch; flex-direction: column; } + .send-task-summary { + grid-template-columns: 1fr; + } + + .send-task-thumb { + width: 100%; + max-width: 180px; + } + + .send-card, + .send-job-item { + grid-template-columns: 1fr; + } + .topbar-actions .primary-button, .primary-button, .secondary-button, diff --git a/app/static/js/app.js b/app/static/js/app.js index 4650865..c323f85 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2238,7 +2238,8 @@ const scheduleSelectedCount = document.querySelector("[data-schedule-selected-co const publishCalendarGrid = document.querySelector("[data-publish-calendar-grid]"); const publishCalendarRange = document.querySelector("[data-publish-calendar-range]"); let publishCalendarStartDate = null; -let draggedScheduleJobId = ""; +let draggedScheduleJobIds = []; +let draggedScheduleTaskCard = null; function setSendCenterMessage(message, tone = "info") { if (!sendCenterMessage) return; @@ -2300,14 +2301,6 @@ function localDateKey(date) { return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; } -function localTimeKey(date) { - return `${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`; -} - -function calendarSlotKey(date) { - return `${localDateKey(date)} ${localTimeKey(date)}`; -} - function localDayStart(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } @@ -2338,22 +2331,6 @@ function scheduleIntervalHours() { return Number.isFinite(value) ? Math.min(168, Math.max(1, value)) : 3; } -function scheduleSlotTimes() { - const start = parseScheduleClock(publishScheduleForm?.elements.daily_start_time?.value, 9); - const end = parseScheduleClock(publishScheduleForm?.elements.daily_end_time?.value, 21); - const startMinutes = start.hour * 60 + start.minute; - const endMinutes = end.hour * 60 + end.minute; - if (endMinutes < startMinutes) { - return [{ hour: start.hour, minute: start.minute }]; - } - const intervalMinutes = scheduleIntervalHours() * 60; - const slots = []; - for (let cursor = startMinutes; cursor <= endMinutes; cursor += intervalMinutes) { - slots.push({ hour: Math.floor(cursor / 60), minute: cursor % 60 }); - } - return slots.length ? slots : [{ hour: start.hour, minute: start.minute }]; -} - function scheduleCardInfo(card) { const form = card?.querySelector("[data-send-job-form]"); const jobId = form?.dataset.jobId || ""; @@ -2363,18 +2340,41 @@ function scheduleCardInfo(card) { return { card, form, jobId, title: title.trim(), platformLabel: platformLabel.trim(), status }; } -function scheduledEntriesBySlot() { +function scheduleTaskTitle(taskCard) { + return taskCard?.querySelector(".send-task-summary h3")?.textContent?.trim() || "未命名任务"; +} + +function scheduleJobIdsForTask(taskCard) { + return String(taskCard?.dataset.scheduleJobIds || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function scheduledEntriesByDate() { const entries = new Map(); - document.querySelectorAll("[data-send-card]").forEach((card) => { - const info = scheduleCardInfo(card); - if (!info.jobId) return; - const scheduledValue = card.querySelector("[data-publish-scheduled-at]")?.dataset.publishScheduledAt || ""; - if (!scheduledValue) return; - const scheduledDate = new Date(scheduledValue); - if (Number.isNaN(scheduledDate.getTime())) return; - const key = calendarSlotKey(scheduledDate); - if (!entries.has(key)) entries.set(key, []); - entries.get(key).push({ ...info, scheduledDate }); + document.querySelectorAll("[data-send-task-card]").forEach((taskCard) => { + const taskEntries = new Map(); + taskCard.querySelectorAll("[data-send-card]").forEach((card) => { + const info = scheduleCardInfo(card); + if (!info.jobId) return; + const scheduledValue = card.querySelector("[data-publish-scheduled-at]")?.dataset.publishScheduledAt || ""; + if (!scheduledValue) return; + const scheduledDate = new Date(scheduledValue); + if (Number.isNaN(scheduledDate.getTime())) return; + const key = localDateKey(scheduledDate); + if (!taskEntries.has(key)) taskEntries.set(key, []); + taskEntries.get(key).push({ ...info, scheduledDate }); + }); + + taskEntries.forEach((items, dateKey) => { + if (!entries.has(dateKey)) entries.set(dateKey, []); + entries.get(dateKey).push({ + taskCard, + taskTitle: scheduleTaskTitle(taskCard), + items, + }); + }); }); return entries; } @@ -2401,96 +2401,99 @@ function platformCalendarTone(platformLabel) { return "default"; } +function formatEntryTimes(items) { + return items + .map((item) => + item.scheduledDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + ) + .join(" / "); +} + function renderPublishCalendar() { if (!publishCalendarGrid) return; if (!publishCalendarStartDate) { publishCalendarStartDate = localWeekStart(calendarSeedDate()); } - const slotTimes = scheduleSlotTimes(); - const entries = scheduledEntriesBySlot(); + const entries = scheduledEntriesByDate(); publishCalendarGrid.replaceChildren(); - publishCalendarGrid.style.setProperty("--calendar-slot-rows", String(slotTimes.length)); if (publishCalendarRange) { publishCalendarRange.textContent = formatCalendarRange(publishCalendarStartDate); } - const corner = document.createElement("div"); - corner.className = "publish-calendar-corner"; - corner.textContent = "时间"; - publishCalendarGrid.append(corner); - for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { const dayDate = addCalendarDays(publishCalendarStartDate, dayIndex); + const dateKey = localDateKey(dayDate); + const dayEntries = entries.get(dateKey) || []; + const column = document.createElement("section"); + column.className = "publish-date-column"; + column.dataset.publishDateColumn = "true"; + column.dataset.targetDate = dateKey; + const heading = document.createElement("div"); - heading.className = "publish-calendar-day"; - heading.innerHTML = `${dayDate.toLocaleDateString([], { weekday: "short" })}${padDatePart( - dayDate.getMonth() + 1 - )}/${padDatePart(dayDate.getDate())}`; - publishCalendarGrid.append(heading); - } - - slotTimes.forEach((slotTime) => { - const timeCell = document.createElement("div"); - timeCell.className = "publish-calendar-time"; - timeCell.textContent = `${padDatePart(slotTime.hour)}:${padDatePart(slotTime.minute)}`; - publishCalendarGrid.append(timeCell); - - for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { - const slotDate = addCalendarDays(publishCalendarStartDate, dayIndex); - slotDate.setHours(slotTime.hour, slotTime.minute, 0, 0); - const slotCell = document.createElement("div"); - slotCell.className = "publish-calendar-slot"; - slotCell.dataset.publishCalendarSlot = "true"; - slotCell.dataset.slotStart = slotDate.toISOString(); - const slotEntries = entries.get(calendarSlotKey(slotDate)) || []; - if (!slotEntries.length) { - const empty = document.createElement("span"); - empty.className = "publish-calendar-empty"; - empty.textContent = "空档"; - slotCell.append(empty); - } - slotEntries.forEach((entry) => { - const chip = document.createElement("button"); - chip.type = "button"; - chip.className = `publish-calendar-chip tone-${platformCalendarTone(entry.platformLabel)}`; - chip.dataset.jobId = entry.jobId; - chip.innerHTML = `${entry.platformLabel}${entry.title}`; - chip.addEventListener("click", () => { - entry.card?.scrollIntoView({ block: "center", behavior: "smooth" }); - entry.card?.classList.add("is-previewing"); - window.setTimeout(() => entry.card?.classList.remove("is-previewing"), 1600); - }); - slotCell.append(chip); - }); - publishCalendarGrid.append(slotCell); + heading.className = "publish-date-heading"; + const weekday = document.createElement("strong"); + weekday.textContent = dayDate.toLocaleDateString([], { weekday: "short" }); + const dateText = document.createElement("span"); + dateText.textContent = `${padDatePart(dayDate.getMonth() + 1)}/${padDatePart(dayDate.getDate())}`; + const count = document.createElement("small"); + count.textContent = `${dayEntries.length} 个任务`; + heading.append(weekday, dateText, count); + column.append(heading); + + const list = document.createElement("div"); + list.className = "publish-date-list"; + if (!dayEntries.length) { + const empty = document.createElement("span"); + empty.className = "publish-date-empty"; + empty.textContent = "把任务卡拖到这里"; + list.append(empty); } - }); + dayEntries.forEach((entry) => { + const platformText = Array.from(new Set(entry.items.map((item) => item.platformLabel))).join(" / "); + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `publish-date-task tone-${platformCalendarTone(platformText)}`; + const meta = document.createElement("span"); + meta.textContent = `${entry.items.length} 条 · ${formatEntryTimes(entry.items)}`; + const title = document.createElement("strong"); + title.textContent = entry.taskTitle; + const platforms = document.createElement("small"); + platforms.textContent = platformText; + chip.append(meta, title, platforms); + chip.addEventListener("click", () => { + entry.taskCard?.scrollIntoView({ block: "center", behavior: "smooth" }); + entry.taskCard?.classList.add("is-previewing"); + window.setTimeout(() => entry.taskCard?.classList.remove("is-previewing"), 1600); + }); + list.append(chip); + }); + column.append(list); + publishCalendarGrid.append(column); + } } -async function scheduleJobAtSlot(jobId, slotStart) { - const slotDate = new Date(slotStart); - if (!jobId || Number.isNaN(slotDate.getTime())) { - setScheduleResult("没有识别到要排期的任务或时间。", "error"); +async function scheduleJobIdsOnDate(jobIds, targetDate, taskCard = null) { + const normalizedJobIds = Array.from(new Set((jobIds || []).map((item) => String(item).trim()).filter(Boolean))); + if (!normalizedJobIds.length || !targetDate) { + setScheduleResult("没有识别到要排期的任务或日期。", "error"); return; } - const card = Array.from(document.querySelectorAll("[data-send-card]")).find( - (item) => item.querySelector("[data-send-job-form]")?.dataset.jobId === jobId - ); - const info = scheduleCardInfo(card); - setScheduleResult(`正在安排:${info.title} -> ${slotDate.toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`); + const title = scheduleTaskTitle(taskCard); + setScheduleResult(`正在安排:${title} -> ${targetDate}`); try { - const response = await fetch("/api/publish/jobs/schedule-batch", { + const response = await fetch("/api/publish/jobs/schedule-date", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - job_ids: [jobId], - action: "apply", - start_at: slotDate.toISOString(), + job_ids: normalizedJobIds, + target_date: targetDate, + start_time: String(publishScheduleForm?.elements.daily_start_time?.value || "09:00"), interval_hours: scheduleIntervalHours(), - daily_start_time: String(publishScheduleForm?.elements.daily_start_time?.value || "09:00"), - daily_end_time: String(publishScheduleForm?.elements.daily_end_time?.value || "21:00"), }), }); const data = await response.json(); @@ -2611,46 +2614,69 @@ formatScheduledTimes(); updateScheduleSelectedCount(); renderPublishCalendar(); -document.querySelectorAll("[data-schedule-draggable]").forEach((card) => { - const info = scheduleCardInfo(card); - if (!info.jobId) return; - card.addEventListener("dragstart", (event) => { - draggedScheduleJobId = info.jobId; - card.classList.add("is-dragging"); - event.dataTransfer?.setData("text/plain", info.jobId); +document.querySelectorAll("[data-schedule-date-input]").forEach((input) => { + if (!input.value) input.value = localDateKey(new Date()); +}); + +document.querySelectorAll("[data-schedule-group-handle]").forEach((handle) => { + const taskCard = handle.closest("[data-send-task-card]"); + const jobIds = scheduleJobIdsForTask(taskCard); + if (!jobIds.length) return; + handle.addEventListener("dragstart", (event) => { + draggedScheduleJobIds = jobIds; + draggedScheduleTaskCard = taskCard; + taskCard?.classList.add("is-dragging"); + event.dataTransfer?.setData("application/x-niuma-job-ids", jobIds.join(",")); + event.dataTransfer?.setData("text/plain", jobIds.join(",")); if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; }); - card.addEventListener("dragend", () => { - draggedScheduleJobId = ""; - card.classList.remove("is-dragging"); - document.querySelectorAll(".publish-calendar-slot.is-drop-target").forEach((slot) => { - slot.classList.remove("is-drop-target"); + handle.addEventListener("dragend", () => { + draggedScheduleJobIds = []; + draggedScheduleTaskCard = null; + taskCard?.classList.remove("is-dragging"); + document.querySelectorAll(".publish-date-column.is-drop-target").forEach((column) => { + column.classList.remove("is-drop-target"); }); }); }); publishCalendarGrid?.addEventListener("dragover", (event) => { - const slot = event.target.closest("[data-publish-calendar-slot]"); - if (!slot) return; + const column = event.target.closest("[data-publish-date-column]"); + if (!column) return; event.preventDefault(); - slot.classList.add("is-drop-target"); + column.classList.add("is-drop-target"); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; }); publishCalendarGrid?.addEventListener("dragleave", (event) => { - const slot = event.target.closest("[data-publish-calendar-slot]"); - if (slot && !slot.contains(event.relatedTarget)) { - slot.classList.remove("is-drop-target"); + const column = event.target.closest("[data-publish-date-column]"); + if (column && !column.contains(event.relatedTarget)) { + column.classList.remove("is-drop-target"); } }); publishCalendarGrid?.addEventListener("drop", (event) => { - const slot = event.target.closest("[data-publish-calendar-slot]"); - if (!slot) return; + const column = event.target.closest("[data-publish-date-column]"); + if (!column) return; event.preventDefault(); - slot.classList.remove("is-drop-target"); - const jobId = event.dataTransfer?.getData("text/plain") || draggedScheduleJobId; - scheduleJobAtSlot(jobId, slot.dataset.slotStart || ""); + column.classList.remove("is-drop-target"); + const csv = + event.dataTransfer?.getData("application/x-niuma-job-ids") || + event.dataTransfer?.getData("text/plain") || + draggedScheduleJobIds.join(","); + const jobIds = csv + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + scheduleJobIdsOnDate(jobIds, column.dataset.targetDate || "", draggedScheduleTaskCard); +}); + +document.querySelectorAll("[data-schedule-group-date]").forEach((button) => { + button.addEventListener("click", () => { + const taskCard = button.closest("[data-send-task-card]"); + const input = taskCard?.querySelector("[data-schedule-date-input]"); + scheduleJobIdsOnDate(scheduleJobIdsForTask(taskCard), input?.value || "", taskCard); + }); }); document.querySelectorAll("[data-publish-calendar-shift]").forEach((button) => { @@ -2749,11 +2775,16 @@ function shouldReloadAfterSendStart(status) { function updateSendFilter(filter) { const normalizedFilter = (filter || "all").toLowerCase(); - document.querySelectorAll("[data-send-card]").forEach((card) => { - const platform = (card.dataset.platform || "").toLowerCase(); - const status = (card.dataset.status || "").toLowerCase(); - const visible = normalizedFilter === "all" || normalizedFilter === platform || normalizedFilter === status; - card.classList.toggle("is-hidden", !visible); + document.querySelectorAll("[data-send-task-card]").forEach((taskCard) => { + let hasVisibleItem = false; + taskCard.querySelectorAll("[data-send-card]").forEach((card) => { + const platform = (card.dataset.platform || "").toLowerCase(); + const status = (card.dataset.status || "").toLowerCase(); + const visible = normalizedFilter === "all" || normalizedFilter === platform || normalizedFilter === status; + card.classList.toggle("is-hidden", !visible); + hasVisibleItem = hasVisibleItem || visible; + }); + taskCard.classList.toggle("is-hidden", !hasVisibleItem); }); } @@ -3033,7 +3064,7 @@ document.querySelectorAll("[data-start-send-queue]").forEach((button) => { document.querySelectorAll("[data-send-select-all]").forEach((checkbox) => { checkbox.addEventListener("change", () => { const visibleCards = Array.from(document.querySelectorAll("[data-send-card]")).filter( - (card) => !card.classList.contains("is-hidden") + (card) => !card.classList.contains("is-hidden") && !card.closest("[data-send-task-card]")?.classList.contains("is-hidden") ); visibleCards.forEach((card) => { const item = card.querySelector("[data-send-job-checkbox]"); diff --git a/app/templates/base.html b/app/templates/base.html index 7cc0eaf..939e681 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,7 +8,7 @@ - + {% block extra_head %}{% endblock %} @@ -61,6 +61,6 @@
{% block extra_scripts %}{% endblock %} - + diff --git a/app/templates/publish.html b/app/templates/publish.html index cfba1e2..131b2ba 100644 --- a/app/templates/publish.html +++ b/app/templates/publish.html @@ -52,8 +52,8 @@

抖音 + B站发送中心

Publish Schedule

-

批量发布计划

-

先勾选下方未发布任务,再统一设置起始时间、发布间隔和每天允许发布的时段。

+

发送日期表

+

把下方“创建任务”卡片拖进某一天,系统会按当天开始时间和间隔自动安排这个任务下的可发送条目。

已选 0 条
@@ -68,11 +68,11 @@

批量发布计划

@@ -89,7 +89,7 @@

批量发布计划

Calendar Board

-

本周发布日历

+

本周发送日期表

@@ -97,7 +97,7 @@

本周发布日历

-
+
@@ -106,8 +106,8 @@

本周发布日历

待发送队列

-

已完成切片自动入队

-

默认每条切片生成抖音和 B站两条发送任务,发送时一次只跑一条,避免平台窗口互相抢焦点。

+

按创建任务管理

+

每张大卡片代表一个创建任务;展开后可分别检查抖音、B站和各条切片发送内容。

- {% for item in send_queue_items %} - {% set can_schedule_item = item.job_id and item.status in ["WAITING", "SCHEDULED", "FAILED", "ready", "failed"] %} -
-
- -
- 封面预览 - 刷新队列后自动选帧 -
-
- -
- - - -
+ {% for group in send_task_groups %} +
+
+
+
+ {% if group.cover_media_url %} + 任务封面 + {% elif group.video_media_url %} + + {% else %} + + {% endif %} +

- {{ item.platform_label }} - {{ item.status_label }} - {{ item.task_name or "未命名任务" }} - {% if item.job_id %} - - 计划: - - - {% endif %} + {{ group.platform_summary }} + {{ group.status_summary }} + {{ group.clip_count }} 条切片 · {{ group.item_count }} 条发送任务

-

{{ item.title }}

-

{{ item.output_file_name or item.output_clip_id }}

+

{{ group.task_name }}

+

任务 ID:{{ group.task_id }}

- {% if item.job_id and item.status in ["WAITING", "SCHEDULED", "FAILED", "ready", "failed"] %} - - {% endif %}
- -
+
+ - - +
+
-
- - - - - -
+
+ {% for item in group["items"] %} + {% set can_schedule_item = item.job_id and item.status in ["WAITING", "SCHEDULED", "FAILED", "ready", "failed"] %} +
+
+ +
+ 封面预览 + 刷新队列后自动选帧 +
+
-
- 系统会先自动选一帧;需要更换时再生成候选帧。 -
+ + + - {% if item.error_message %} -

{{ item.error_message }}

- {% endif %} - {% if item.platform_url %} - 查看平台链接 - {% endif %} +
+
+

+ {{ item.platform_label }} + {{ item.status_label }} + {% if item.job_id %} + + 计划: + + + {% endif %} +

+

{{ item.title }}

+

{{ item.output_file_name or item.output_clip_id }}

+
+ {% if item.job_id and item.status in ["WAITING", "SCHEDULED", "FAILED", "ready", "failed"] %} + + {% endif %} +
-
-

- {% if item.job_id %}任务编号:{{ item.job_id }}{% else %}还未入队,先点“刷新发送队列”。{% endif %} -

-
- - - - -
-
- +
+ + + +
+ +
+ + + + + +
+ +
+ 系统会先自动选一帧;需要更换时再生成候选帧。 +
+ + {% if item.error_message %} +

{{ item.error_message }}

+ {% endif %} + {% if item.platform_url %} + 查看平台链接 + {% endif %} + +
+

+ {% if item.job_id %}任务编号:{{ item.job_id }}{% else %}还未入队,先点“刷新发送队列”。{% endif %} +

+
+ + + + +
+
+ +
+ {% endfor %} +
{% else %}
diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index 138b9ff..f5883c2 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,12 +1,11 @@ # UI 参考说明 -## 2026-07-02 更新:发送中心日历拖拽排期 -- 发送中心“批量发布计划”面板新增 7 天日历时间表,沿用 Apple 风格浅色卡片、蓝色强调色、8px 圆角和轻量边框。 -- 日历按照“每日开始 / 每日结束 / 间隔小时”生成时间格,支持上一周、本周、下一周切换;在桌面宽度下横向展示,窄屏保持横向滚动,避免挤压文字。 -- 下方发送任务卡片在 `WAITING`、`SCHEDULED`、`FAILED` 等可排期状态下支持拖拽;拖入日历时间格后为该任务设置发布时间。 -- 已排期任务会在日历格中显示平台色块,点击色块会定位到对应任务卡片,方便核对视频、标题和平台。 -- 已发布任务的按钮文案改为“已发布”,并用禁用状态和提示说明不能重复发送,避免用户把灰色按钮理解为页面失灵。 -- 不新增 React / Vue,不改变右侧投稿预览和现有发送队列结构;批量表单仍保留,用于一次性给多条任务排期。 +## 2026-07-02 更新:发送中心日期表与任务卡片管理 +- 发送中心排期区改为 7 天日期表,沿用 Apple 风格浅色卡片、蓝色强调色、8px 圆角和轻量边框,不再展示时间轴 / 小时格。 +- 日期表按日期列展示,支持上一周、本周、下一周切换;每列显示当天已排期的创建任务卡片摘要,空列提示“把任务卡拖到这里”。 +- 待发送队列按创建任务分组,每张大卡片代表一个创建任务,卡片内展示抖音 / B站和各条切片发送条目。 +- 任务卡顶部提供“拖到日期”手柄,避免视频、输入框和按钮干扰拖拽;拖拽失败时可选择兜底日期并点击“安排到这一天”。 +- 已发布任务继续显示“已发布”禁用状态;发送中心仍保留人工确认和 opencli 平台边界,不新增 React / Vue。 ## 2026-06-25 更新:全自动入口精简与发送中心批量排期 - 新建任务页的全自动模式只保留“新建后自动跑完整流水线”开关;自动切片数量直接使用上方候选片段数量,时长上限直接使用单条切片最长。 diff --git a/scripts/test_send_center_opencli_queue.py b/scripts/test_send_center_opencli_queue.py index a629e12..0d20cc1 100644 --- a/scripts/test_send_center_opencli_queue.py +++ b/scripts/test_send_center_opencli_queue.py @@ -166,6 +166,12 @@ def test_send_center_frontend_publishing_overlay_resources() -> None: assert "updateSendPreviewFromForm" in js assert "data-send-preview-description" in html assert "is-previewing" in css + assert "send_task_groups" in html + assert "data-send-task-card" in html + assert "publish-date-board" in html + assert "schedule-date" in js + assert ".publish-date-column" in css + assert ".send-task-card" in css assert "opencli_status.restart_command" in html assert "请继续使用 Docker 主页面" in html assert "http://127.0.0.1:8001" in html diff --git a/tests/test_publish_scheduler.py b/tests/test_publish_scheduler.py index ba6a294..fd0ffc1 100644 --- a/tests/test_publish_scheduler.py +++ b/tests/test_publish_scheduler.py @@ -15,7 +15,7 @@ from app.models.task import PublishSendStart from app.services import publish_service from app.services.auto_publish_service import create_auto_publish_jobs -from app.services.publish_scheduler import PublishScheduler, build_batch_schedule_times +from app.services.publish_scheduler import PublishScheduler, build_batch_schedule_times, build_date_schedule_times from app.services.publish_service import get_publish_job @@ -395,6 +395,23 @@ def test_batch_schedule_can_be_cleared(tmp_path): assert get_publish_job(job_id)["status"] == "WAITING" +def test_date_schedule_keeps_task_group_on_selected_date(tmp_path): + first_job = _insert_job(tmp_path, status="WAITING", scheduled_at="") + second_job = _insert_job(tmp_path, status="WAITING", scheduled_at="") + + result = PublishScheduler().update_date_schedule( + [first_job, second_job], + target_date="2026-06-25", + start_time="20:00", + interval_hours=3, + ) + + assert result["updated_count"] == 2 + assert get_publish_job(first_job)["scheduled_at"].startswith("2026-06-25T20:00:00") + assert get_publish_job(second_job)["scheduled_at"].startswith("2026-06-25T23:00:00") + assert get_publish_job(first_job)["status"] == "SCHEDULED" + + def test_batch_schedule_time_builder_rejects_invalid_daily_window(): with pytest.raises(ValueError, match="结束时间必须晚于"): build_batch_schedule_times( @@ -404,3 +421,8 @@ def test_batch_schedule_time_builder_rejects_invalid_daily_window(): daily_start_time="21:00", daily_end_time="09:00", ) + + +def test_date_schedule_time_builder_rejects_invalid_date(): + with pytest.raises(ValueError, match="目标日期格式无效"): + build_date_schedule_times(1, target_date="2026/06/25", start_time="09:00", interval_hours=3) From 3f04b3b4af1702691679716b2693706ce13ba74a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 22:23:56 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E5=8F=91?= =?UTF-8?q?=E9=80=81=E4=B8=AD=E5=BF=83=E6=8C=89=E4=BB=BB=E5=8A=A1=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=B9=B6=E4=BC=98=E5=85=88=E6=8A=96=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- DEVELOPMENT_LOG.md | 8 ++ NEXT_STEPS.md | 10 +++ README.md | 12 ++- app/services/publish_service.py | 21 +++-- app/static/css/styles.css | 61 +++++++++++++++ app/static/js/app.js | 11 ++- app/templates/base.html | 4 +- app/templates/publish.html | 82 ++++++++------------ docs/DEPLOYMENT.md | 12 ++- docs/PROJECT_GUIDE.md | 30 +++++-- docs/UI_REFERENCE.md | 7 ++ scripts/install_opencli_helper_autostart.ps1 | 46 +++++++++++ scripts/start_docker_opencli.ps1 | 56 +------------ scripts/start_opencli_host_bridge.ps1 | 65 ++++++++++++++++ scripts/test_send_center_opencli_queue.py | 21 ++++- start_niuma_studio_docker.cmd | 19 +++++ 17 files changed, 338 insertions(+), 129 deletions(-) create mode 100644 scripts/install_opencli_helper_autostart.ps1 create mode 100644 scripts/start_opencli_host_bridge.ps1 create mode 100644 start_niuma_studio_docker.cmd diff --git a/.env.example b/.env.example index 047a9e8..cbe7b4e 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,7 @@ AI_LOCAL_FALLBACK_PROTOCOL= AI_LOCAL_HEALTH_TIMEOUT_SECONDS=30 # 发送中心 opencli -# Docker 主页面固定使用 8001;Windows opencli 辅助服务由 scripts/start_docker_opencli.ps1 启动。 +# Docker 主页面固定使用 8001;Windows opencli 辅助服务推荐双击 start_niuma_studio_docker.cmd 一起启动。 OPENCLI_LOCAL_BASE_URL=http://127.0.0.1:8001 OPENCLI_HOST_BRIDGE_URL=http://host.docker.internal:8765 diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 8149098..4312adb 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,13 @@ # Development Log +## 2026-07-09 抖音优先发送中心与 Docker opencli 启动体验 +- 发送中心当前范围收敛为抖音优先:刷新队列、页面队列、统计和发送记录只展示抖音任务;B站相关后端能力保留,但暂不显示在发送中心。 +- 待发送队列从平铺卡片改为按创建任务折叠分组,默认展开第一期;任务组摘要显示任务名、切片数量、状态和“拖到日期”手柄,便于按一期一期管理。 +- 页面移除 B站筛选、B站投稿字段和 B站预览说明;发送确认文案只提醒检查抖音创作者中心登录态。 +- 新增 `start_niuma_studio_docker.cmd` 双击启动器,并拆出 `scripts/start_opencli_host_bridge.ps1`;`scripts/start_docker_opencli.ps1` 现在复用独立辅助服务脚本后再刷新 Docker。 +- 新增 `scripts/install_opencli_helper_autostart.ps1`,可选地把 Windows opencli 辅助服务设置为当前用户登录后自启动;文档明确说明 Docker 容器不能直接启动 Windows Chrome,自动发送仍需宿主机辅助服务。 +- 静态资源版本更新为 `20260709-douyin-task-groups`,并同步更新 README、部署文档、项目指南、UI 参考和测试脚本。 + ## 2026-07-02 发送中心日期表与任务卡片管理 - 发送中心排期区改为 7 天“发送日期表”,只按日期列展示,不再生成时间轴 / 小时格;支持上一周、本周、下一周切换。 - 待发送队列改为按创建任务分组,每张大卡片代表一个创建任务,卡片内保留抖音 / B站和各条切片的发送内容编辑、封面帧、保存和发送操作。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index e0c191d..ed701a2 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,15 @@ # Next Steps +## 2026-07-09 抖音优先发送中心怎么测试 +1. 先确认 Docker Desktop 已经打开。 +2. 在项目根目录双击 `start_niuma_studio_docker.cmd`,它会启动 Windows opencli 辅助服务、刷新 Docker,并打开 `http://127.0.0.1:8001/publish`。 +3. 页面打开后按 `Ctrl + F5` 强制刷新一次,确认加载 `20260709-douyin-task-groups` 版资源。 +4. 点击“刷新抖音队列”,确认发送中心只出现抖音任务;不应再看到 B站筛选按钮、B站分区、B站声明或 B站投稿预览。 +5. 在“按创建任务分组”区域检查:同一个创建任务生成的多个视频应收在同一组里;默认展开第一组,其他组可以点任务标题展开/收起。 +6. 需要排期时,把任务组顶部“拖到日期”拖进日期列;如果拖不动,就在组内选择兜底日期并点“安排这一期到这一天”。 +7. 发送前只需要确认 Chrome 已登录抖音创作者中心;当前不要测试 B站发送,等抖音流程稳定后再恢复 B站显示。 +8. 如果页面提示 opencli 辅助服务未连接,仍可整理队列和排期;自动发送前重新双击 `start_niuma_studio_docker.cmd`。 + ## 2026-07-02 发送中心日期表怎么测试 1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。 2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确认页面加载 `20260702-publish-date-board` 版资源。 diff --git a/README.md b/README.md index af69aab..283aca4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ v1.4.0 已实现定时发送与自动发布执行器:系统会按 `publish_job - 视频处理:已接入 FFmpeg / FFprobe,用于音频提取、切片、封面帧和字幕成片。 - 转写:支持火山引擎远程转写和本地 faster-whisper。 - AI 分析:支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama;长视频会按小段分析再合并候选片段。 -- 发送中心:支持生成抖音 / B站待发送队列、AI 标题 / 简介 / 话题、候选封面帧,并通过 opencli 调用已登录 Chrome 辅助投稿。 +- 发送中心:当前优先生成和管理抖音待发送队列,支持 AI 标题 / 简介 / 话题、候选封面帧,并通过 opencli 调用已登录 Chrome 辅助投稿;B站能力先保留在代码里,暂不显示在发送中心主流程。 - 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会保存账号密码、cookie 或真实 API Key。 - 配置安全:真实 `.env` 已被 Git 忽略,不会提交真实 API Key。 - 品牌说明:当前页面主名为“牛马片场”,英文代号为 `NiuMa Studio`,Docker 技术名为 `niuma-studio`。 @@ -121,7 +121,14 @@ pytest --cov=app --cov-report=term-missing ## Docker 启动 -推荐启动方式:Docker 一键启动。 +推荐启动方式:双击项目根目录的 `start_niuma_studio_docker.cmd`。 + +这个启动器会同时做两件事: + +- 启动 Windows opencli 辅助服务,用来控制你已经登录的 Windows Chrome。 +- 刷新 Docker 服务并打开 `http://127.0.0.1:8001/publish`。 + +如果你只想启动 Docker 页面、不使用发送中心自动发送,也可以手动运行: ```powershell docker compose up --build @@ -144,6 +151,7 @@ Docker 启动说明: - `.env` 文件会被自动加载(如果存在) - 存储目录 `E:\直播间切片工作流存储` 会自动挂载到容器内 - 代码目录和 prompts 目录以 volume 方式挂载,支持热更新 +- Docker 容器不能直接启动 Windows Chrome;抖音自动发送需要 Windows opencli 辅助服务,所以推荐使用根目录双击启动器。 --- diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 85e1d1f..da390c9 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -101,6 +101,9 @@ } ) +SEND_CENTER_PLATFORMS = ("douyin",) +SEND_CENTER_PLATFORM_LABELS = {platform: PLATFORM_LABELS[platform] for platform in SEND_CENTER_PLATFORMS} + PUBLISH_MODE_LABELS.update( { "manual_export": "手动发布包导出", @@ -359,7 +362,7 @@ def _opencli_local_port() -> int: def _opencli_restart_command() -> str: - return ".\\scripts\\start_docker_opencli.ps1" + return "start_niuma_studio_docker.cmd" def _opencli_status() -> dict: @@ -381,7 +384,7 @@ def _opencli_status() -> dict: elif bridge.get("available"): status["message"] = "Docker 8001 已连接 Windows opencli 辅助服务,可以使用发送中心自动发送。" else: - status["message"] = "Docker 页面已启动,但还没有连接到 Windows opencli 辅助服务。发送中心可以先整理队列,自动发送需要先启动辅助服务。" + status["message"] = "Docker 页面已启动,但还没有连接到 Windows opencli 辅助服务。发送中心可以先整理抖音队列,自动发送需要 Windows 辅助服务。" return status @@ -1189,7 +1192,7 @@ def ensure_cover_for_item() -> dict: errors.append(f"{item.get('output_file_name') or item.get('output_clip_id')} / 自动封面:{exc}") return cover_state["cover"] or {} - for platform in PLATFORM_LABELS: + for platform in SEND_CENTER_PLATFORMS: existing_job = _find_opencli_job(item["output_clip_id"], platform) if existing_job: skipped += 1 @@ -2821,7 +2824,7 @@ def get_publish_center_context() -> dict: } publish_items.append(normalized_item) jobs_for_oc = opencli_jobs_map.get(item["output_clip_id"], {}) - for platform in PLATFORM_LABELS: + for platform in SEND_CENTER_PLATFORMS: job = jobs_for_oc.get(platform) if job: queue_items.append( @@ -2865,10 +2868,14 @@ def get_publish_center_context() -> dict: ) task_groups = _build_send_task_groups(queue_items) - jobs = list_publish_jobs(limit=200) + jobs = [ + job + for job in list_publish_jobs(limit=200) + if job.get("platform") in SEND_CENTER_PLATFORMS + ] jobs_by_platform = { platform: [job for job in jobs if job["platform"] == platform] - for platform in PLATFORM_LABELS + for platform in SEND_CENTER_PLATFORMS } ready_count = sum(1 for job in jobs if job.get("status") in {PUBLISH_STATUS_SCHEDULED, PUBLISH_STATUS_WAITING}) sending_count = sum(1 for job in jobs if job.get("status") == PUBLISH_STATUS_PUBLISHING) @@ -2882,7 +2889,7 @@ def get_publish_center_context() -> dict: "send_task_groups": task_groups, "publish_jobs": jobs, "jobs_by_platform": jobs_by_platform, - "platforms": [{"id": platform, "label": label} for platform, label in PLATFORM_LABELS.items()], + "platforms": [{"id": platform, "label": label} for platform, label in SEND_CENTER_PLATFORM_LABELS.items()], "opencli_available": opencli_status["available"], "opencli_status": opencli_status, "stats": [ diff --git a/app/static/css/styles.css b/app/static/css/styles.css index ebeed71..585f2dc 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -3005,6 +3005,12 @@ body.transcript-drawer-open .main-panel { color: var(--blue); } +.inline-alert.tone-amber { + border-color: rgba(217, 134, 34, 0.2); + background: var(--amber-soft); + color: var(--amber); +} + .publish-console { display: grid; gap: 18px; @@ -3346,6 +3352,24 @@ body.transcript-drawer-open .main-panel { box-shadow: var(--shadow-soft); } +.send-task-card > summary { + list-style: none; + cursor: pointer; +} + +.send-task-card > summary::-webkit-details-marker { + display: none; +} + +.send-task-card:not([open]) { + gap: 0; +} + +.send-task-card:not([open]) .send-task-card-header { + padding-bottom: 0; + border-bottom: 0; +} + .send-task-card.is-previewing, .send-task-card.is-dragging { border-color: rgba(37, 111, 255, 0.34); @@ -3410,12 +3434,37 @@ body.transcript-drawer-open .main-panel { overflow-wrap: anywhere; } +.send-task-summary-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex: 0 0 auto; + flex-wrap: wrap; +} + +.send-task-toggle-text { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 10px; + border-radius: 8px; + background: var(--blue-soft); + color: var(--blue); + font-size: 12px; + font-weight: 800; +} + .send-task-schedule-controls { display: flex; align-items: end; justify-content: flex-end; gap: 8px; flex-wrap: wrap; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); } .send-task-schedule-controls label { @@ -3438,6 +3487,12 @@ body.transcript-drawer-open .main-panel { cursor: grabbing; } +.schedule-drag-handle.is-disabled { + cursor: not-allowed; + opacity: 0.56; + pointer-events: none; +} + .send-task-items { display: grid; gap: 12px; @@ -4706,6 +4761,12 @@ fieldset input:focus { box-shadow: var(--shadow-soft); } +.inline-alert.tone-amber { + border-color: rgba(217, 135, 36, 0.16); + background: rgba(255, 244, 223, 0.9); + color: var(--amber); +} + .empty-state { border: 1px solid rgba(31, 45, 71, 0.08); border-radius: 8px; diff --git a/app/static/js/app.js b/app/static/js/app.js index c323f85..3acf9fd 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2397,7 +2397,6 @@ function formatCalendarRange(startDate) { function platformCalendarTone(platformLabel) { if (platformLabel.includes("抖音")) return "douyin"; - if (platformLabel.includes("B站")) return "bilibili"; return "default"; } @@ -2449,7 +2448,7 @@ function renderPublishCalendar() { if (!dayEntries.length) { const empty = document.createElement("span"); empty.className = "publish-date-empty"; - empty.textContent = "把任务卡拖到这里"; + empty.textContent = "把某一期拖到这里"; list.append(empty); } dayEntries.forEach((entry) => { @@ -2622,6 +2621,10 @@ document.querySelectorAll("[data-schedule-group-handle]").forEach((handle) => { const taskCard = handle.closest("[data-send-task-card]"); const jobIds = scheduleJobIdsForTask(taskCard); if (!jobIds.length) return; + handle.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); handle.addEventListener("dragstart", (event) => { draggedScheduleJobIds = jobIds; draggedScheduleTaskCard = taskCard; @@ -2976,7 +2979,7 @@ document.querySelectorAll("[data-send-single-job]").forEach((button) => { cardStatus === "SCHEDULED" ? "\n\n注意:这条任务已经设置了发布时间,确认后会立即发送,不再等待原计划时间。" : ""; - if (!window.confirm(`确认开始发送这一条吗?请先确认 Chrome 已登录对应平台。${scheduledNotice}`)) return; + if (!window.confirm(`确认开始发送这一条抖音任务吗?请先确认 Chrome 已登录抖音创作者中心。${scheduledNotice}`)) return; const originalText = button.textContent; button.disabled = true; button.textContent = "发送中..."; @@ -3021,7 +3024,7 @@ document.querySelectorAll("[data-start-send-queue]").forEach((button) => { selectedIds.length && selectedStatusSummary.scheduledCount ? `\n\n其中 ${selectedStatusSummary.scheduledCount} 条已排期任务会立即发送,不再等待原计划时间。` : "\n\n未勾选时只会发送等待处理/发送失败任务,不会发送未来排期任务。"; - if (!window.confirm(`确认开始发送 ${label} 吗?\n\n请先确认 Chrome 已登录抖音创作者中心和 B站创作中心。${scheduledNotice}`)) return; + if (!window.confirm(`确认开始发送 ${label} 吗?\n\n请先确认 Chrome 已登录抖音创作者中心。${scheduledNotice}`)) return; const originalText = button.textContent; button.disabled = true; button.textContent = "启动中..."; diff --git a/app/templates/base.html b/app/templates/base.html index 939e681..3e3d996 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,7 +8,7 @@ - + {% block extra_head %}{% endblock %} @@ -61,6 +61,6 @@
{% block extra_scripts %}{% endblock %} - + diff --git a/app/templates/publish.html b/app/templates/publish.html index 131b2ba..0161c2a 100644 --- a/app/templates/publish.html +++ b/app/templates/publish.html @@ -5,14 +5,14 @@ {% block content %}
-

Send Center 2.0

-

抖音 + B站发送中心

-

切好的视频、自动封面帧、AI 标题和平台 #话题在这里排队;确认后由 opencli 使用 Chrome 登录态逐条发送。

+

Douyin Send Center

+

抖音发送中心

+

按创建任务归类管理每一期切片;确认标题、正文、#话题和封面后,由 opencli 使用 Windows Chrome 登录态逐条发送到抖音。

- + - +
@@ -21,11 +21,12 @@

抖音 + B站发送中心

{% endif %} {% if not opencli_available %} -
+
{{ opencli_status.message }} -

请继续使用 Docker 主页面 http://127.0.0.1:8001,不要切到第二个网页。

-

推荐处理:在项目目录运行 {{ opencli_status.restart_command }},脚本会启动 Windows opencli 辅助服务、刷新 Docker,并打开 {{ opencli_status.publish_url }}

-

页面打开后按 Ctrl + F5 强制刷新,再回到发送中心测试自动发送。

+

Docker 页面可以继续整理抖音队列和排期;真正自动发送时,仍需要 Windows 侧辅助服务来控制已经登录的 Chrome。

+

项目根目录已提供 start_niuma_studio_docker.cmd,以后双击它即可同时启动 Windows opencli 辅助服务和 Docker 页面,不需要每次手动输入命令。

+

如果双击后浏览器没有自动打开,请继续访问 Docker 主页面 http://127.0.0.1:8001/publish

+

技术限制说明:Docker 容器不能直接启动 Windows Chrome,否则拿不到你的登录态;所以辅助服务必须运行在 Windows 宿主机上。

{% endif %} @@ -52,8 +53,8 @@

抖音 + B站发送中心

Publish Schedule

-

发送日期表

-

把下方“创建任务”卡片拖进某一天,系统会按当天开始时间和间隔自动安排这个任务下的可发送条目。

+

抖音发送日期表

+

按“创建任务”整组排期:把某一期拖进某一天,系统会按当天开始时间和间隔安排这期下面的抖音切片。

已选 0 条
@@ -106,8 +107,8 @@

本周发送日期表

待发送队列

-

按创建任务管理

-

每张大卡片代表一个创建任务;展开后可分别检查抖音、B站和各条切片发送内容。

+

按创建任务分组

+

一组就是你创建的一期任务,例如“测试1”生成的 10 个视频会收在同一组里;默认只显示抖音任务,B站先不进入当前工作流。

+ {% else %}

还没有可发送的切片

@@ -314,7 +301,6 @@

网页自动化会填写这些内容

{{ preview.description }}

抖音:上传视频,填写标题/正文 #话题,设置封面,点击发布。

-

B站:上传视频,填写封面、标题、分区、标签、简介,点击立即投稿。

遇到验证码、登录失效或风控弹窗时,任务会进入失败,等待人工处理。

{% else %} @@ -327,7 +313,7 @@

网页自动化会填写这些内容

发送记录

-

opencli 任务状态

+

抖音 opencli 任务状态

diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7a50a66..d64d275 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -114,6 +114,16 @@ http://127.0.0.1:8001 ### 3.3 构建并启动 +如果要使用发送中心自动发抖音,优先双击项目根目录的: + +```text +start_niuma_studio_docker.cmd +``` + +它会先启动 Windows opencli 辅助服务,再刷新 Docker 页面。Docker 容器不能直接启动 Windows Chrome,所以需要这个宿主机辅助服务来复用你的抖音登录态。 + +如果只启动 Docker 页面、不使用自动发送,可以运行: + ```powershell docker compose up --build ``` @@ -144,7 +154,7 @@ docker compose down - **存储目录**:`docker-compose.yml` 默认将 `E:\直播间切片工作流存储` 挂载到容器内 `/workspace/tasks`。如果你的存储目录在其他位置,请修改 `docker-compose.yml` 中的 `volumes` 配置。 - **代码热更新**:`app/` 和 `prompts/` 目录以 volume 方式挂载,修改代码后容器自动重载。 - **Ollama 连接**:如果 Ollama 在宿主机运行,容器内通过 `http://host.docker.internal:11434/v1` 访问。 -- **opencli 桥接**:容器内通过 `http://host.docker.internal:8765` 访问宿主机上的 opencli 桥接服务。 +- **opencli 桥接**:容器内通过 `http://host.docker.internal:8765` 访问宿主机上的 opencli 桥接服务。推荐用 `start_niuma_studio_docker.cmd` 一起启动;如需开机自启,可运行一次 `.\scripts\install_opencli_helper_autostart.ps1`。 --- diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 6a3659b..e009dc2 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -34,7 +34,15 @@ Docker 的好处是:不用每次手动激活 `.venv`,端口映射清楚, 第一次启动前,先确认 Docker Desktop 已经打开。 -然后打开 PowerShell,进入项目目录: +如果你要用发送中心自动发抖音,最推荐的方式是直接双击项目根目录的: + +```text +start_niuma_studio_docker.cmd +``` + +这个文件会同时启动 Windows opencli 辅助服务、刷新 Docker,并打开 Docker 主页面。你不需要每次手动输入 PowerShell 命令。 + +如果你只想启动 Docker 页面、不使用自动发送,再打开 PowerShell,进入项目目录: ```powershell cd "C:\Users\10578\Documents\New project 2" @@ -46,7 +54,7 @@ cd "C:\Users\10578\Documents\New project 2" docker compose up --build ``` -如果你要使用发送中心自动发送,推荐改用这一条。它会同时启动 Windows opencli 辅助服务和 Docker 主页面: +如果你更习惯命令行,也可以运行这一条,效果和双击启动器一样: ```powershell .\scripts\start_docker_opencli.ps1 @@ -209,20 +217,30 @@ http://127.0.0.1:8001 如果 AI 提示缺少 Key,先到系统状态页检查对应的三类接口:音频转写看火山引擎 Key,文字稿分析看 `AI_ANALYSIS_REMOTE_API_KEY`,发送中心文案看 `AI_PUBLISH_REMOTE_API_KEY`。 -如果发送中心提示“还没有连接到 Windows opencli 辅助服务”,先不要点“开始发送全部”。日常仍然只使用 Docker 主页面 `http://127.0.0.1:8001`,按下面顺序处理: +如果发送中心提示“还没有连接到 Windows opencli 辅助服务”,先不要点“开始发送抖音队列”。日常仍然只使用 Docker 主页面 `http://127.0.0.1:8001`。 + +最简单的处理方式:回到项目根目录,双击: + +```text +start_niuma_studio_docker.cmd +``` + +如果你想以后开机后自动准备好辅助服务,可以只设置一次 Windows 自启动: ```powershell cd "C:\Users\10578\Documents\New project 2" -.\scripts\start_docker_opencli.ps1 +.\scripts\install_opencli_helper_autostart.ps1 ``` -脚本会自动检查 Windows opencli、启动 opencli 辅助服务、刷新 Docker,并打开 `http://127.0.0.1:8001/publish`。页面打开后按 `Ctrl + F5` 强制刷新。如果脚本提示“没有检测到 opencli”,再执行: +注意:Docker 容器不能直接启动 Windows Chrome,否则拿不到抖音登录态;所以 opencli 辅助服务必须运行在 Windows 宿主机上。双击启动器负责把这两边一起拉起来。 + +如果脚本提示“没有检测到 opencli”,再执行: ```powershell where opencli ``` -如果 `where opencli` 没有显示路径,说明 opencli 还没装好或没有加入 Windows PATH;如果能显示路径但页面仍报错,把页面红色提示和 `where opencli` 输出发给开发助手继续排查。 +如果 `where opencli` 没有显示路径,说明 opencli 还没装好或没有加入 Windows PATH;如果能显示路径但页面仍报错,把页面顶部 opencli 提示和 `where opencli` 输出发给开发助手继续排查。 如果转写速度很慢,可能是没有使用 NVIDIA 显卡。可以在 `.env` 中把转写配置改成 CPU 模式,但速度会慢一些。 diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index f5883c2..e57f602 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,12 @@ # UI 参考说明 +## 2026-07-09 更新:抖音优先发送中心与任务组管理 +- 发送中心标题收敛为“抖音发送中心”,当前工作流只展示抖音队列;B站发送能力暂不删除,但不再出现在发送中心主界面、筛选器、表单字段和投稿预览中。 +- 待发送队列改成按创建任务折叠分组:例如“测试1”生成的 10 条视频会收在同一组里,默认展开第一组,其他任务可按需展开,减少平铺式视频卡片带来的管理负担。 +- 日期表空列文案改为“把某一期拖到这里”,任务组顶部保留“拖到日期”手柄;兜底日期按钮改为“安排这一期到这一天”,强调按一期排期。 +- opencli 未连接状态从强报错改为黄色提醒,并提示使用根目录 `start_niuma_studio_docker.cmd` 双击启动器;说明 Docker 不能直接启动 Windows Chrome,自动发送仍通过 Windows opencli 辅助服务复用登录态。 +- 静态资源版本更新为 `20260709-douyin-task-groups`,避免 Docker 重启后继续加载旧版发送中心 CSS / JS。 + ## 2026-07-02 更新:发送中心日期表与任务卡片管理 - 发送中心排期区改为 7 天日期表,沿用 Apple 风格浅色卡片、蓝色强调色、8px 圆角和轻量边框,不再展示时间轴 / 小时格。 - 日期表按日期列展示,支持上一周、本周、下一周切换;每列显示当天已排期的创建任务卡片摘要,空列提示“把任务卡拖到这里”。 diff --git a/scripts/install_opencli_helper_autostart.ps1 b/scripts/install_opencli_helper_autostart.ps1 new file mode 100644 index 0000000..530dfff --- /dev/null +++ b/scripts/install_opencli_helper_autostart.ps1 @@ -0,0 +1,46 @@ +param( + [int]$BridgePort = 8765 +) + +$ErrorActionPreference = "Stop" + +$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$HelperScript = Join-Path $ProjectRoot "scripts\start_opencli_host_bridge.ps1" +$TaskName = "NiuMa Studio OpenCLI Helper" +$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + +$action = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$HelperScript`" -BridgePort $BridgePort" +$trigger = New-ScheduledTaskTrigger -AtLogOn +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Seconds 0) +$principal = New-ScheduledTaskPrincipal ` + -UserId $CurrentUser ` + -LogonType Interactive ` + -RunLevel LeastPrivilege + +Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Description "Start NiuMa Studio Windows opencli helper for Docker send center." ` + -Force | Out-Null + +Start-ScheduledTask -TaskName $TaskName +Start-Sleep -Seconds 2 + +$previousErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "SilentlyContinue" +$health = Invoke-WebRequest -Uri "http://127.0.0.1:$BridgePort/health" -UseBasicParsing -TimeoutSec 5 +$ErrorActionPreference = $previousErrorActionPreference + +if ($health) { + Write-Host "NiuMa Studio opencli helper autostart is installed and running." +} else { + Write-Host "Autostart task was installed, but the helper is not responding yet. Please check opencli_bridge_$BridgePort.err.log." +} diff --git a/scripts/start_docker_opencli.ps1 b/scripts/start_docker_opencli.ps1 index c5a6547..5906ef8 100644 --- a/scripts/start_docker_opencli.ps1 +++ b/scripts/start_docker_opencli.ps1 @@ -8,61 +8,7 @@ $ErrorActionPreference = "Stop" $ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $ProjectRoot -if ($env:APPDATA) { - $npmDir = Join-Path $env:APPDATA "npm" - if (Test-Path $npmDir) { - $env:PATH = "$npmDir;$env:PATH" - } -} - -$opencli = Get-Command opencli -ErrorAction SilentlyContinue -if (-not $opencli) { - Write-Host 'opencli was not found. Please install opencli and make sure where opencli returns a path.' - exit 1 -} -Write-Host ('opencli found: {0}' -f $opencli.Source) - -$bridgeConnections = Get-NetTCPConnection -LocalPort $BridgePort -State Listen -ErrorAction SilentlyContinue -$bridgeProcessIds = @($bridgeConnections | Select-Object -ExpandProperty OwningProcess -Unique) -foreach ($processId in $bridgeProcessIds) { - if (-not $processId -or $processId -eq $PID) { - continue - } - $process = Get-Process -Id $processId -ErrorAction SilentlyContinue - if ($process) { - Write-Host ('Stopping old opencli helper: PID {0} ({1})' -f $processId, $process.ProcessName) - Stop-Process -Id $processId -Force - } -} - -$python = Join-Path $ProjectRoot ".venv\Scripts\python.exe" -if (-not (Test-Path $python)) { - $python = "python" -} - -$bridgeOutLog = Join-Path $ProjectRoot "opencli_bridge_$BridgePort.out.log" -$bridgeErrLog = Join-Path $ProjectRoot "opencli_bridge_$BridgePort.err.log" -Write-Host ('Starting Windows opencli helper: http://127.0.0.1:{0}' -f $BridgePort) -$bridgeScript = Join-Path $ProjectRoot "scripts\opencli_host_bridge.py" -$bridgeArguments = @("`"$bridgeScript`"", "--host", "0.0.0.0", "--port", "$BridgePort") -Start-Process ` - -FilePath $python ` - -ArgumentList $bridgeArguments ` - -WorkingDirectory $ProjectRoot ` - -RedirectStandardOutput $bridgeOutLog ` - -RedirectStandardError $bridgeErrLog ` - -WindowStyle Hidden - -Start-Sleep -Seconds 2 -$previousErrorActionPreference = $ErrorActionPreference -$ErrorActionPreference = "SilentlyContinue" -$bridgeHealth = Invoke-WebRequest -Uri "http://127.0.0.1:$BridgePort/health" -UseBasicParsing -TimeoutSec 5 -$ErrorActionPreference = $previousErrorActionPreference -if ($bridgeHealth) { - Write-Host 'opencli helper is running.' -} else { - Write-Host ('opencli helper is not responding yet. Log: {0}' -f $bridgeErrLog) -} +& (Join-Path $PSScriptRoot "start_opencli_host_bridge.ps1") -BridgePort $BridgePort Write-Host 'Cleaning old Docker services that may occupy port 8001.' docker compose down --remove-orphans diff --git a/scripts/start_opencli_host_bridge.ps1 b/scripts/start_opencli_host_bridge.ps1 new file mode 100644 index 0000000..6d2f333 --- /dev/null +++ b/scripts/start_opencli_host_bridge.ps1 @@ -0,0 +1,65 @@ +param( + [int]$BridgePort = 8765 +) + +$ErrorActionPreference = "Stop" + +$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $ProjectRoot + +if ($env:APPDATA) { + $npmDir = Join-Path $env:APPDATA "npm" + if (Test-Path $npmDir) { + $env:PATH = "$npmDir;$env:PATH" + } +} + +$opencli = Get-Command opencli -ErrorAction SilentlyContinue +if (-not $opencli) { + Write-Host "opencli was not found. Please install opencli and make sure 'where opencli' returns a path." + exit 1 +} +Write-Host ("opencli found: {0}" -f $opencli.Source) + +$bridgeConnections = Get-NetTCPConnection -LocalPort $BridgePort -State Listen -ErrorAction SilentlyContinue +$bridgeProcessIds = @($bridgeConnections | Select-Object -ExpandProperty OwningProcess -Unique) +foreach ($processId in $bridgeProcessIds) { + if (-not $processId -or $processId -eq $PID) { + continue + } + $process = Get-Process -Id $processId -ErrorAction SilentlyContinue + if ($process) { + Write-Host ("Stopping old opencli helper: PID {0} ({1})" -f $processId, $process.ProcessName) + Stop-Process -Id $processId -Force + } +} + +$python = Join-Path $ProjectRoot ".venv\Scripts\python.exe" +if (-not (Test-Path $python)) { + $python = "python" +} + +$bridgeOutLog = Join-Path $ProjectRoot "opencli_bridge_$BridgePort.out.log" +$bridgeErrLog = Join-Path $ProjectRoot "opencli_bridge_$BridgePort.err.log" +$bridgeScript = Join-Path $ProjectRoot "scripts\opencli_host_bridge.py" +$bridgeArguments = @("`"$bridgeScript`"", "--host", "0.0.0.0", "--port", "$BridgePort") + +Write-Host ("Starting Windows opencli helper: http://127.0.0.1:{0}" -f $BridgePort) +Start-Process ` + -FilePath $python ` + -ArgumentList $bridgeArguments ` + -WorkingDirectory $ProjectRoot ` + -RedirectStandardOutput $bridgeOutLog ` + -RedirectStandardError $bridgeErrLog ` + -WindowStyle Hidden + +Start-Sleep -Seconds 2 +$previousErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "SilentlyContinue" +$bridgeHealth = Invoke-WebRequest -Uri "http://127.0.0.1:$BridgePort/health" -UseBasicParsing -TimeoutSec 5 +$ErrorActionPreference = $previousErrorActionPreference +if ($bridgeHealth) { + Write-Host "opencli helper is running." +} else { + Write-Host ("opencli helper is not responding yet. Log: {0}" -f $bridgeErrLog) +} diff --git a/scripts/test_send_center_opencli_queue.py b/scripts/test_send_center_opencli_queue.py index 0d20cc1..fc5c7e3 100644 --- a/scripts/test_send_center_opencli_queue.py +++ b/scripts/test_send_center_opencli_queue.py @@ -168,12 +168,12 @@ def test_send_center_frontend_publishing_overlay_resources() -> None: assert "is-previewing" in css assert "send_task_groups" in html assert "data-send-task-card" in html + assert "
None: assert "OPENCLI_HOST_BRIDGE_URL=http://host.docker.internal:8765" in env_example +def test_send_center_is_douyin_first_for_now() -> None: + html = (PROJECT_ROOT / "app" / "templates" / "publish.html").read_text(encoding="utf-8") + js = (PROJECT_ROOT / "app" / "static" / "js" / "app.js").read_text(encoding="utf-8") + + assert publish_service.SEND_CENTER_PLATFORMS == ("douyin",) + assert "抖音发送中心" in html + assert 'data-send-filter="bilibili"' not in html + assert 'name="bilibili_tid"' not in html + assert 'name="bilibili_copyright"' not in html + assert "Chrome 已登录抖音创作者中心和 B站创作中心" not in js + assert "Chrome 已登录抖音创作者中心" in js + + def test_douyin_description_copies_body_and_platform_topics_directly() -> None: body = "陈亦飞回忆当年在美国陪S姐妹游玩,目睹小S在雨中打电话给妈妈,回房间后对着镜子自信爆棚,大喊“我真的超正的!”,真实又可爱,满满青春回忆" topics = "#小S自恋名场面 #青春回忆杀 #明星搞笑日常 #反差萌瞬间 #姐妹花趣事" @@ -428,7 +441,7 @@ def test_opencli_missing_status_tells_user_how_to_restart() -> None: status = publish_service._opencli_status() # noqa: SLF001 assert not status["available"] - assert "start_docker_opencli.ps1" in status["restart_command"] + assert "start_niuma_studio_docker.cmd" in status["restart_command"] assert status["publish_url"].endswith("/publish") assert "Docker 页面已启动" in status["message"] finally: @@ -519,6 +532,8 @@ def main() -> None: print("opencli cleanup leaves browser: OK") test_send_center_frontend_publishing_overlay_resources() print("send center frontend publishing overlay: OK") + test_send_center_is_douyin_first_for_now() + print("send center douyin-first scope: OK") test_douyin_description_copies_body_and_platform_topics_directly() print("douyin direct description topics: OK") test_bilibili_browser_commands() diff --git a/start_niuma_studio_docker.cmd b/start_niuma_studio_docker.cmd new file mode 100644 index 0000000..383f6b3 --- /dev/null +++ b/start_niuma_studio_docker.cmd @@ -0,0 +1,19 @@ +@echo off +chcp 65001 >nul +setlocal + +cd /d "%~dp0" + +echo 正在启动牛马片场 Docker 和 Windows opencli 辅助服务... +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start_docker_opencli.ps1" +if errorlevel 1 ( + echo. + echo 启动失败:请确认 Docker Desktop 已打开,并且 Windows 已安装 opencli。 + echo 如果这里显示 opencli 找不到,请先安装 opencli 后再双击本文件。 + pause + exit /b 1 +) + +echo. +echo 已完成启动。如果浏览器没有自动打开,请访问 http://127.0.0.1:8001/publish +pause