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 7dbbde5..4312adb 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,27 @@ # 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站和各条切片的发送内容编辑、封面帧、保存和发送操作。 +- 任务卡顶部新增“拖到日期”手柄,只拖动任务组本身,避免视频、输入框和按钮干扰拖拽;同时保留选择日期后点击“安排到这一天”的兜底操作。 +- 新增 `PATCH /api/publish/jobs/schedule-date`,用于把任务组下可排期条目安排到指定日期;不新增数据库字段,仍写入现有 `publish_jobs.scheduled_at`。 +- 静态资源版本更新为 `20260702-publish-date-board`,确保 Docker 页面刷新后加载新的 CSS / JS。 + +## 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..ed701a2 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,34 @@ # 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` 版资源。 +3. 在“发送日期表”里查看 7 天日期列;可以点“上一周 / 本周 / 下一周”切换时间范围。 +4. 下方“待发送队列”应该按创建任务显示为大卡片,每张任务卡里再展示抖音 / B站和切片发送条目。 +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` 版脚本。 +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/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/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 e921901..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 @@ -554,6 +557,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( @@ -1121,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 @@ -2565,13 +2636,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 +2660,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 +2679,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 +2697,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) @@ -2735,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( @@ -2778,10 +2867,15 @@ def get_publish_center_context() -> dict: } ) - jobs = list_publish_jobs(limit=200) + task_groups = _build_send_task_groups(queue_items) + 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) @@ -2792,9 +2886,10 @@ 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()], + "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 8c2c0c4..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; @@ -3336,23 +3342,181 @@ 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 > 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); + 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-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 { + 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; +} + +.schedule-drag-handle.is-disabled { + cursor: not-allowed; + opacity: 0.56; + pointer-events: none; +} + +.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 +3827,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 +3847,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, @@ -3869,6 +4043,142 @@ 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-date-board { + display: grid; + grid-template-columns: repeat(7, minmax(154px, 1fr)); + gap: 10px; + min-width: 980px; +} + +.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-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-date-heading { + display: grid; + gap: 4px; + padding: 8px; + border-radius: 8px; + background: #f6f9ff; +} + +.publish-date-heading strong { + font-size: 14px; +} + +.publish-date-heading span, +.publish-date-heading small { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.publish-date-list { + display: grid; + align-content: start; + gap: 8px; +} + +.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: 800; +} + +.publish-date-task { + display: grid; + gap: 4px; + width: 100%; + padding: 9px 10px; + border: 1px solid rgba(37, 111, 255, 0.18); + border-radius: 8px; + background: #eef5ff; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.publish-date-task span { + color: var(--primary); + font-size: 11px; + font-weight: 800; +} + +.publish-date-task strong { + display: -webkit-box; + overflow: hidden; + font-size: 12px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.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-date-task.tone-bilibili span { + color: #14825a; +} + +.publish-date-task.tone-default { + border-color: rgba(111, 125, 148, 0.22); + background: #f4f7fb; +} + +.publish-date-task.tone-default span { + color: #637083; +} + .schedule-time-note { color: var(--muted); font-size: 12px; @@ -3883,6 +4193,11 @@ body.transcript-drawer-open .main-panel { .publish-schedule-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .publish-calendar-toolbar, + .publish-date-board { + min-width: 900px; + } } @media (max-width: 640px) { @@ -4316,7 +4631,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); @@ -4444,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; @@ -4457,6 +4780,7 @@ fieldset input:focus { .review-task-main h3, .send-card-header h3, +.send-task-summary h3, .publish-job-row h3 { color: #111827; } @@ -4637,12 +4961,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); } @@ -4855,12 +5182,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 543b51b..3acf9fd 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2235,6 +2235,11 @@ 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 draggedScheduleJobIds = []; +let draggedScheduleTaskCard = null; function setSendCenterMessage(message, tone = "info") { if (!sendCenterMessage) return; @@ -2288,6 +2293,219 @@ 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 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 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 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-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; +} + +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"; + 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 entries = scheduledEntriesByDate(); + publishCalendarGrid.replaceChildren(); + if (publishCalendarRange) { + publishCalendarRange.textContent = formatCalendarRange(publishCalendarStartDate); + } + + 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-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 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 title = scheduleTaskTitle(taskCard); + setScheduleResult(`正在安排:${title} -> ${targetDate}`); + + try { + const response = await fetch("/api/publish/jobs/schedule-date", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + job_ids: normalizedJobIds, + target_date: targetDate, + start_time: String(publishScheduleForm?.elements.daily_start_time?.value || "09:00"), + interval_hours: scheduleIntervalHours(), + }), + }); + 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 +2611,93 @@ document.querySelector("[data-clear-batch-schedule]")?.addEventListener("click", formatScheduledTimes(); updateScheduleSelectedCount(); +renderPublishCalendar(); + +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("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + 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"; + }); + 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 column = event.target.closest("[data-publish-date-column]"); + if (!column) return; + event.preventDefault(); + column.classList.add("is-drop-target"); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; +}); + +publishCalendarGrid?.addEventListener("dragleave", (event) => { + 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 column = event.target.closest("[data-publish-date-column]"); + if (!column) return; + event.preventDefault(); + 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) => { + 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); @@ -2453,13 +2758,36 @@ 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) => { - 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); }); } @@ -2646,12 +2974,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 +2997,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 +3018,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 已登录抖音创作者中心。${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 +3046,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"); @@ -2712,7 +3067,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 a905f14..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 62e028e..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 条
@@ -68,11 +69,11 @@

批量发布计划

@@ -85,6 +86,20 @@

批量发布计划

+
+
+
+

Calendar Board

+

本周发送日期表

+
+
+ + + +
+
+
+
@@ -92,8 +107,8 @@

批量发布计划

待发送队列

-

已完成切片自动入队

-

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

+

按创建任务分组

+

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